diff --git a/.coderabbit.yaml b/.coderabbit.yaml new file mode 100644 index 00000000..2408084c --- /dev/null +++ b/.coderabbit.yaml @@ -0,0 +1,6 @@ +# yaml-language-server: $schema=https://coderabbit.ai/integrations/schema.v2.json +reviews: + auto_review: + # Dependabot bumps are checked by CI; skip CodeRabbit reviews on them. + ignore_usernames: + - "dependabot[bot]" diff --git a/.github/dependabot.yml b/.github/dependabot.yml new file mode 100644 index 00000000..d39962e1 --- /dev/null +++ b/.github/dependabot.yml @@ -0,0 +1,32 @@ +version: 2 +updates: + # Maintain Rust / Cargo dependencies + - package-ecosystem: "cargo" + directories: + - "/" + - "/plugins" + schedule: + interval: "daily" + time: "06:00" + timezone: "America/New_York" + # Consolidate updates into a single PR rather than one PR per crate + groups: + cargo-dependencies: + patterns: + - "*" + commit-message: + prefix: "chore(deps)" + + # Maintain GitHub Actions workflows + - package-ecosystem: "github-actions" + directory: "/" + schedule: + interval: "daily" + time: "06:00" + timezone: "America/New_York" + groups: + actions: + patterns: + - "*" + commit-message: + prefix: "ci(deps)" diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index e613617a..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@v4 - - - 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@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@v2 - 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@v4 - - uses: dtolnay/rust-toolchain@stable - with: - targets: wasm32-unknown-unknown - - uses: Swatinem/rust-cache@v2 - 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 b01af052..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: @@ -32,4 +32,4 @@ jobs: uses: storytold/craft-common-actions/.github/workflows/freebsd.yml@main with: run-package-check: true - craft-fonts-ref: 43913056ce82a3fdad0ef69f6b88d1dc922a8104 + craft-fonts-ref: a71c20e30af613287c504f7e685ca8fad77752bc diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 75f7a5fa..dd2aefdd 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -24,8 +24,14 @@ jobs: with: app-name: figcraft app-title: FigCraft + # Needs fonts/latin-manifest.txt (Inter): crates/text/build.rs fails a release 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 has-server: true + # The browser has no system fonts: without craft-fonts the web build can't draw text (#885). + # crates/text/build.rs keeps the web build to Inter. + web-craft-fonts: true version: ${{ inputs.version }} secrets: APPLE_CERTIFICATE: ${{ secrets.APPLE_CERTIFICATE }} diff --git a/.github/workflows/sonarcloud.yml b/.github/workflows/sonarcloud.yml new file mode 100644 index 00000000..e78fe1ea --- /dev/null +++ b/.github/workflows/sonarcloud.yml @@ -0,0 +1,21 @@ +name: SonarCloud + +on: + push: + branches: [main] + pull_request: + types: [opened, synchronize, reopened] + +permissions: + contents: read + +concurrency: + group: sonarcloud-${{ github.ref }} + # 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: + # Tracked at @main so org-wide CI improvements propagate without PR churn. + uses: storytold/craft-common-actions/.github/workflows/sonarcloud.yml@main + secrets: inherit diff --git a/AGENTS.md b/AGENTS.md index 708da805..b2384311 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -81,6 +81,7 @@ code. L6 depends on egui/eframe/rfd/winit. **The UI crate is swappable.** - **The UI is thin**: panels read engine state and act through `app.run(id, params)` / `app.set_props(ids, props)`. Colours come from `theme::Tokens`. +- **Time goes through `chrono`** (workspace dependency). Never hand-roll UTC/calendar conversion (days-to-date algorithms, leap-year math); a crate owns only its output format string. The one exception is range extension: past the years chrono can represent, shift the date by whole 400-year Gregorian cycles (146,097 days, after which the calendar repeats), let chrono compute the month and day, and add the cycles back to the year (splitting seconds into hours, minutes and seconds is plain arithmetic). - **Rust only** (no handwritten JS/TS). **Never break wasm** (`cargo xtask wasm`). ### Quality gates (before every commit) diff --git a/ATTRIBUTION.md b/ATTRIBUTION.md index 1f72aeb7..96dff6a9 100644 --- a/ATTRIBUTION.md +++ b/ATTRIBUTION.md @@ -25,6 +25,7 @@ Release builds embed fonts from the optional craft-fonts build input | Family | Licence | Upstream | |---|---|---| | Inter | SIL Open Font License 1.1 | https://github.com/rsms/inter | +| Archivo | SIL Open Font License 1.1 | [Google Fonts / Archivo](https://github.com/google/fonts/tree/6c70c829f09ea345d3590406693220ea35c6553f/ofl/archivo) | | Roboto, Montserrat, Lato, Open Sans, Poppins, Nunito Sans, Merriweather, Playfair Display, Source Sans 3, Source Serif 4, Carlito, Caladea | SIL Open Font License 1.1 | Google Fonts / upstream repositories (see craft-fonts) | | Liberation Sans, Serif, Mono | SIL Open Font License 1.1 | https://github.com/liberationfonts | | BIZ UDPGothic, BIZ UDMincho, Shippori Mincho, Noto Sans Arabic | SIL Open Font License 1.1 | Google Fonts (see craft-fonts) | diff --git a/CLAUDE.md b/CLAUDE.md index 708da805..b2384311 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -81,6 +81,7 @@ code. L6 depends on egui/eframe/rfd/winit. **The UI crate is swappable.** - **The UI is thin**: panels read engine state and act through `app.run(id, params)` / `app.set_props(ids, props)`. Colours come from `theme::Tokens`. +- **Time goes through `chrono`** (workspace dependency). Never hand-roll UTC/calendar conversion (days-to-date algorithms, leap-year math); a crate owns only its output format string. The one exception is range extension: past the years chrono can represent, shift the date by whole 400-year Gregorian cycles (146,097 days, after which the calendar repeats), let chrono compute the month and day, and add the cycles back to the year (splitting seconds into hours, minutes and seconds is plain arithmetic). - **Rust only** (no handwritten JS/TS). **Never break wasm** (`cargo xtask wasm`). ### Quality gates (before every commit) diff --git a/Cargo.lock b/Cargo.lock index 86bccfa3..27cb0d4e 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -244,9 +244,9 @@ dependencies = [ [[package]] name = "arboard" -version = "3.6.1" +version = "3.7.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0348a1c054491f4bfe6ab86a7b6ab1e44e45d899005de92f58b3df180b36ddaf" +checksum = "aeb9553e62312c8756a5979254fc6d7da8bbeb5986a1e17037fab932f7037917" dependencies = [ "clipboard-win", "image", @@ -258,7 +258,7 @@ dependencies = [ "objc2-foundation 0.3.2", "parking_lot", "percent-encoding", - "windows-sys 0.60.2", + "windows-sys 0.61.2", "wl-clipboard-rs", "x11rb", ] @@ -392,7 +392,7 @@ checksum = "4f8abc12baad266b1c8cec146854c195b5864b4221d4b2ca7296a7ae82d9e451" dependencies = [ "proc-macro2", "quote", - "syn 3.0.6", + "syn 3.0.9", ] [[package]] @@ -427,7 +427,7 @@ checksum = "82f6aeea286b8eb4dd3431a1be1b59d290ace00f5bfd8e2a159bc2a05e2c1667" dependencies = [ "proc-macro2", "quote", - "syn 3.0.6", + "syn 3.0.9", ] [[package]] @@ -491,15 +491,6 @@ version = "0.23.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ac07cdecf99051d9a5238b80f35af32cdeba5b336e55d957b318b50137e18da5" -[[package]] -name = "bit-set" -version = "0.8.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "08807e080ed7f9d5433fa9b275196cfc35414f66a0c79d864dc51a0d825231a3" -dependencies = [ - "bit-vec 0.8.0", -] - [[package]] name = "bit-set" version = "0.10.0" @@ -510,10 +501,13 @@ dependencies = [ ] [[package]] -name = "bit-vec" -version = "0.8.0" +name = "bit-set" +version = "0.11.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5e764a1d40d510daf35e07be9eb06e75770908c27d411ee6c92109c9840eaaf7" +checksum = "56d87354e4229f54a44f7bf2435906a4656dba36026ab6eaca629a2c436a691c" +dependencies = [ + "bit-vec 0.10.1", +] [[package]] name = "bit-vec" @@ -521,6 +515,16 @@ version = "0.9.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b71798fca2c1fe1086445a7258a4bc81e6e49dcd24c8d0dd9a1e57395b603f51" +[[package]] +name = "bit-vec" +version = "0.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5727b15fa97d4f4fee0a3b7c3d550ed0269f54329207b86388de918604e31269" +dependencies = [ + "borsh", + "serde", +] + [[package]] name = "bitflags" version = "1.3.2" @@ -582,6 +586,30 @@ dependencies = [ "piper", ] +[[package]] +name = "borsh" +version = "1.8.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "553c5d846a6ba5150c65e3b1b8ec073bcf1abc20f9b7220de384a4443ea4e20a" +dependencies = [ + "borsh-derive", + "bytes", + "cfg_aliases", +] + +[[package]] +name = "borsh-derive" +version = "1.8.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "12cdfe656708a01f89b451a7d36466e6fe6c414de0aa18fc54f864f6f9ca9f56" +dependencies = [ + "once_cell", + "proc-macro-crate", + "proc-macro2", + "quote", + "syn 3.0.9", +] + [[package]] name = "bumpalo" version = "3.20.3" @@ -605,7 +633,7 @@ checksum = "6a1f896587b6f2c069c73d2f0913e2d590c3990285cd2f0b6aa02b786b4c679c" dependencies = [ "proc-macro2", "quote", - "syn 3.0.6", + "syn 3.0.9", ] [[package]] @@ -672,9 +700,9 @@ dependencies = [ [[package]] name = "cc" -version = "1.6.0" +version = "1.9.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f74872d07caf508b30a21f6836e7d7016a2eaf7d9ff4f48deaa58cd8a0407630" +checksum = "877bf67ac8e52449eb2f9e98a344ea163beb70dd1f9eebe3cc5357dcfeaee4df" dependencies = [ "find-msvc-tools", "jobserver", @@ -714,6 +742,15 @@ dependencies = [ "rand_core 0.10.1", ] +[[package]] +name = "chrono" +version = "0.4.45" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1aa79e62e7697b8e29b513a68abacf485adcd1fe8284a4316c5ae868e6633327" +dependencies = [ + "num-traits", +] + [[package]] name = "clipboard-win" version = "5.4.1" @@ -766,9 +803,9 @@ checksum = "1d07550c9036bf2ae0c684c4297d503f838287c83c53686d05370d0e139ae570" [[package]] name = "combine" -version = "4.6.8" +version = "4.6.9" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "cfc320937d09e6de266b31b9afb480f197d7a861be86be7cb2ea7e5d1bfffc5e" +checksum = "4356eba6976435498d05a311ee9fc486da3bc81c200d3f0d7dd9703b41913f92" dependencies = [ "bytes", "memchr", @@ -829,6 +866,12 @@ dependencies = [ "libc", ] +[[package]] +name = "core_detect" +version = "1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7f8f80099a98041a3d1622845c271458a2d73e688351bf3cb999266764b81d48" + [[package]] name = "core_maths" version = "0.1.1" @@ -861,11 +904,20 @@ name = "craft-docpath" version = "0.1.0" source = "git+https://github.com/storytold/craft-libs?rev=82a24b006eefa036b43c09a67fb9a3fbd17ffe63#82a24b006eefa036b43c09a67fb9a3fbd17ffe63" +[[package]] +name = "craft-window" +version = "0.1.0" +source = "git+https://github.com/storytold/craft-libs?rev=1f805378d454dac48b5c214029d254055fdf961b#1f805378d454dac48b5c214029d254055fdf961b" +dependencies = [ + "egui", + "wayland-client", +] + [[package]] name = "crc32fast" -version = "1.5.2" +version = "1.5.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "01a7799fd6b852db0e61728dde9a204c423b44d689dbd432522543614b490e78" +checksum = "0ba106e844045ddc02c20768ac4b0b7cb45f2cc2ecbd833ac9f5dacb183c2055" dependencies = [ "cfg-if", ] @@ -951,9 +1003,9 @@ checksum = "be1e0bca6c3637f992fc1cc7cbc52a78c1ef6db076dbf1059c4323d6a2048376" [[package]] name = "defmt" -version = "1.1.1" +version = "1.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e2953bfe4f93bbd20cc71198842756f77d161884c99ebbabc41d80231ded88d1" +checksum = "e4a0795ac740cb54d7b3775c9c17345f6aaecf884c01733b39d6017738e0e3e1" dependencies = [ "bitflags 1.3.2", "defmt-macros", @@ -961,21 +1013,21 @@ dependencies = [ [[package]] name = "defmt-macros" -version = "1.1.1" +version = "1.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "bad9c72e7ca2137e0dc3813245a0d282fd6daad32fd800af018306a9169b5fe8" +checksum = "840bb056bd99c495c9188198a258ab787c79f306d864db3e6e8451442d6d8366" dependencies = [ "defmt-parser", "proc-macro2", "quote", - "syn 2.0.119", + "syn 3.0.9", ] [[package]] name = "defmt-parser" -version = "1.0.0" +version = "1.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "10d60334b3b2e7c9d91ef8150abfb6fa4c1c39ebbcf4a81c2e346aad939fee3e" +checksum = "71958f69fb2de856c5dd51ac9377e986ca1aa5919f931bf5395a807277f889a0" dependencies = [ "thiserror 2.0.21", ] @@ -988,7 +1040,7 @@ checksum = "1b034bd7d5f032402a2479444dcc6f74e36a03f31854d41680fb240ef682a1ac" dependencies = [ "proc-macro2", "quote", - "syn 3.0.6", + "syn 3.0.9", ] [[package]] @@ -1038,7 +1090,7 @@ checksum = "c6232dd377dcc64799954cbd3a9bb882e9cdc1308ccd87b1c098f1fb2eaf82a8" dependencies = [ "proc-macro2", "quote", - "syn 3.0.6", + "syn 3.0.9", ] [[package]] @@ -1313,7 +1365,7 @@ dependencies = [ "ecolor", "emath", "epaint_default_fonts", - "font-types 0.12.6", + "font-types 0.12.7", "harfrust 0.12.0", "log", "nohash-hasher", @@ -1335,9 +1387,9 @@ checksum = "773fa9c96dd0dbef887e39d0ed6177f141cce4d68e6041df77570aa3702dfa13" [[package]] name = "equivalent" -version = "1.0.2" +version = "1.0.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "877a4ace8713b0bcf2a4e7eec82529c029f1d0619886d18145fea96c3ffe5c0f" +checksum = "00d174d5400e5e8fd687ad1049e2f578285fa914201b1af7e8b112a4546bd826" [[package]] name = "errno" @@ -1413,9 +1465,10 @@ checksum = "b97b65636e5b9ef369943878ac74335ba1c55c1cb6adbf1e2c293c624248d693" [[package]] name = "figcraft" -version = "0.7.0" +version = "0.10.0" dependencies = [ "arboard", + "craft-window", "eframe", "egui", "env_logger", @@ -1427,11 +1480,9 @@ dependencies = [ "fmv-macos-events", "image", "log", - "raw-window-handle", "rfd", "serde_json", "wat", - "wayland-client", "webbrowser", "windows", "winit", @@ -1440,7 +1491,7 @@ dependencies = [ [[package]] name = "figcraft-cli" -version = "0.7.0" +version = "0.10.0" dependencies = [ "figcraft-doc", "figcraft-engine", @@ -1454,9 +1505,10 @@ dependencies = [ [[package]] name = "figcraft-collab" -version = "0.7.0" +version = "0.10.0" dependencies = [ "base64 0.22.1", + "chrono", "figcraft-color", "figcraft-doc", "figcraft-geom", @@ -1469,7 +1521,7 @@ dependencies = [ [[package]] name = "figcraft-collab-net" -version = "0.7.0" +version = "0.10.0" dependencies = [ "base64 0.22.1", "figcraft-collab", @@ -1492,7 +1544,7 @@ dependencies = [ [[package]] name = "figcraft-color" -version = "0.7.0" +version = "0.10.0" dependencies = [ "kurbo", "serde", @@ -1501,7 +1553,7 @@ dependencies = [ [[package]] name = "figcraft-doc" -version = "0.7.0" +version = "0.10.0" dependencies = [ "figcraft-color", "figcraft-geom", @@ -1513,7 +1565,7 @@ dependencies = [ [[package]] name = "figcraft-engine" -version = "0.7.0" +version = "0.10.0" dependencies = [ "base64 0.22.1", "craft-docpath", @@ -1541,7 +1593,7 @@ dependencies = [ [[package]] name = "figcraft-format" -version = "0.7.0" +version = "0.10.0" dependencies = [ "figcraft-doc", "figcraft-pathops", @@ -1556,7 +1608,7 @@ dependencies = [ [[package]] name = "figcraft-geom" -version = "0.7.0" +version = "0.10.0" dependencies = [ "kurbo", "serde", @@ -1564,7 +1616,7 @@ dependencies = [ [[package]] name = "figcraft-layout" -version = "0.7.0" +version = "0.10.0" dependencies = [ "figcraft-doc", "figcraft-geom", @@ -1572,7 +1624,7 @@ dependencies = [ [[package]] name = "figcraft-mcp" -version = "0.7.0" +version = "0.10.0" dependencies = [ "base64 0.22.1", "figcraft-collab-net", @@ -1588,7 +1640,7 @@ dependencies = [ [[package]] name = "figcraft-pathops" -version = "0.7.0" +version = "0.10.0" dependencies = [ "figcraft-doc", "figcraft-geom", @@ -1597,7 +1649,7 @@ dependencies = [ [[package]] name = "figcraft-pdf" -version = "0.7.0" +version = "0.10.0" dependencies = [ "figcraft-color", "figcraft-doc", @@ -1614,7 +1666,7 @@ dependencies = [ [[package]] name = "figcraft-plugin" -version = "0.7.0" +version = "0.10.0" dependencies = [ "figcraft-color", "serde", @@ -1627,7 +1679,7 @@ dependencies = [ [[package]] name = "figcraft-render" -version = "0.7.0" +version = "0.10.0" dependencies = [ "crc32fast", "figcraft-color", @@ -1645,7 +1697,7 @@ dependencies = [ [[package]] name = "figcraft-server" -version = "0.7.0" +version = "0.10.0" dependencies = [ "crc32fast", "ctrlc", @@ -1669,7 +1721,7 @@ dependencies = [ [[package]] name = "figcraft-svg" -version = "0.7.0" +version = "0.10.0" dependencies = [ "base64 0.22.1", "figcraft-color", @@ -1685,7 +1737,7 @@ dependencies = [ [[package]] name = "figcraft-text" -version = "0.7.0" +version = "0.10.0" dependencies = [ "figcraft-doc", "figcraft-geom", @@ -1699,9 +1751,10 @@ dependencies = [ [[package]] name = "figcraft-ui-egui" -version = "0.7.0" +version = "0.10.0" dependencies = [ "base64 0.22.1", + "chrono", "egui", "egui_extras", "figcraft-color", @@ -1724,7 +1777,7 @@ dependencies = [ [[package]] name = "figcraft-web" -version = "0.7.0" +version = "0.10.0" dependencies = [ "eframe", "egui", @@ -1740,9 +1793,9 @@ dependencies = [ [[package]] name = "find-msvc-tools" -version = "0.1.14" +version = "0.1.15" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "aedcfb3409746eddb02b9e19ebda1c3394f759a152e48ee875a0844d1b955484" +checksum = "1c66915cc5391097de7a2b0b972e8505aa33c0df401fd3a6811dbdb32881ba48" [[package]] name = "fixedbitset" @@ -1810,9 +1863,9 @@ dependencies = [ [[package]] name = "font-types" -version = "0.12.6" +version = "0.12.7" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6c92d9081c7ab3bd83ef1eecdfad090f3d966e3ce74106779664114cfe1a6274" +checksum = "835ae9d781464a79026cde387f9cbc650c9a4909d7cf7e658b127e3c35d2f35d" dependencies = [ "bytemuck", ] @@ -1857,7 +1910,7 @@ checksum = "ea5190182e6915eb873ddbc16e23b711b6eb1f9c00a0d0a3a91b5f6228475225" dependencies = [ "proc-macro2", "quote", - "syn 3.0.6", + "syn 3.0.9", ] [[package]] @@ -1908,7 +1961,7 @@ checksum = "9fb9654ba8355388abeb8dcb4fc62f511300867002afc858860463bdd9fe0c44" dependencies = [ "proc-macro2", "quote", - "syn 3.0.6", + "syn 3.0.9", ] [[package]] @@ -2606,9 +2659,9 @@ checksum = "09edd9e8b54e49e587e4f6295a7d29c3ea94d469cb40ab8ca70b288248a81db2" [[package]] name = "libc" -version = "0.2.190" +version = "0.2.191" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ce5d3ddc6d3fa000eb1536d85e147bfe31aacaba692ed6a876f95cb7c855be78" +checksum = "65cd87c9a6d60536580b316ad985726104730a5151c99845f2490428ef91240a" [[package]] name = "libloading" @@ -2635,7 +2688,7 @@ dependencies = [ "bitflags 2.13.2", "libc", "plain", - "redox_syscall 0.9.4", + "redox_syscall 0.9.5", ] [[package]] @@ -2677,9 +2730,9 @@ checksum = "47d9d19d1d6efa0109d2f65ff4c85cddd50bd572e5a00127ab10987290bcefae" [[package]] name = "litrs" -version = "1.0.0" +version = "1.0.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "11d3d7f243d5c5a8b9bb5d6dd2b1602c0cb0b9db1621bafc7ed66e35ff9fe092" +checksum = "4744e383959f0db86ede514b809b1c53251889093803c05267acc7d4e7030d70" [[package]] name = "lock_api" @@ -3236,7 +3289,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7d8fae84b431384b68627d0f9b3b1245fcf9f46f6c0e3dc902e9dce64edd1967" dependencies = [ "libc", - "windows-sys 0.52.0", + "windows-sys 0.61.2", ] [[package]] @@ -3492,15 +3545,6 @@ dependencies = [ "zerovec", ] -[[package]] -name = "ppv-lite86" -version = "0.2.21" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "85eae3c4ed2f50dcfe72643da4befc30deadb458a9b590d720cde2f2b1e97da9" -dependencies = [ - "zerocopy", -] - [[package]] name = "presser" version = "0.3.1" @@ -3533,16 +3577,17 @@ checksum = "3d595e54a326bc53c1c197b32d295e14b169e3cfeaa8dc82b529f947fba6bcf5" [[package]] name = "proptest" -version = "1.11.0" +version = "1.12.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4b45fcc2344c680f5025fe57779faef368840d0bd1f42f216291f0dc4ace4744" +checksum = "b8530004ccb15eae51c7e40009fbe317f341f804db54dc033eec1c50be28cfa0" dependencies = [ - "bit-set 0.8.0", - "bit-vec 0.8.0", + "bit-set 0.11.1", + "bit-vec 0.10.1", "bitflags 2.13.2", + "chacha20", + "core_detect", "num-traits", - "rand 0.9.5", - "rand_chacha", + "rand", "rand_xorshift", "regex-syntax", "rusty-fork", @@ -3598,16 +3643,6 @@ version = "6.0.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f8dcc9c7d52a811697d2151c701e0d08956f92b0e24136cf4cf27b57a6a0d9bf" -[[package]] -name = "rand" -version = "0.9.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b9ef1d0d795eb7d84685bca4f72f3649f064e6641543d3a8c415898726a57b41" -dependencies = [ - "rand_chacha", - "rand_core 0.9.5", -] - [[package]] name = "rand" version = "0.10.3" @@ -3619,24 +3654,11 @@ dependencies = [ "rand_core 0.10.1", ] -[[package]] -name = "rand_chacha" -version = "0.9.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d3022b5f1df60f26e1ffddd6c66e8aa15de382ae63b3a0c1bfc0e4d3e3f325cb" -dependencies = [ - "ppv-lite86", - "rand_core 0.9.5", -] - [[package]] name = "rand_core" version = "0.9.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "76afc826de14238e6e8c374ddcc1fa19e374fd8dd986b0d2af0d02377261d83c" -dependencies = [ - "getrandom 0.3.4", -] [[package]] name = "rand_core" @@ -3646,11 +3668,11 @@ checksum = "63b8176103e19a2643978565ca18b50549f6101881c443590420e4dc998a3c69" [[package]] name = "rand_xorshift" -version = "0.4.0" +version = "0.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "513962919efc330f829edb2535844d1b912b0fbe2ca165d613e4e8788bb05a5a" +checksum = "60aa6af80be32871323012e02e6e65f8a7cc7890931ae421d217ad8fe0df2ccf" dependencies = [ - "rand_core 0.9.5", + "rand_core 0.10.1", ] [[package]] @@ -3723,7 +3745,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "046a7d674daf459825b32f5062056d6882db0d2f5a479fbd76ccfc870ac18709" dependencies = [ "bytemuck", - "font-types 0.12.6", + "font-types 0.12.7", "once_cell", ] @@ -3734,7 +3756,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "005c8acf251756c478b0bf402885bfd88a1476020c4c7e6060edc1aa68da38ea" dependencies = [ "bytemuck", - "font-types 0.12.6", + "font-types 0.12.7", "once_cell", ] @@ -3745,7 +3767,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d5a0cf4bb6cbce4c29756270d189a5e400ac05e91192161d83a0bcb6e68492e8" dependencies = [ "bytemuck", - "font-types 0.12.6", + "font-types 0.12.7", "once_cell", ] @@ -3769,9 +3791,9 @@ dependencies = [ [[package]] name = "redox_syscall" -version = "0.9.4" +version = "0.9.5" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "737970939a87c6fa31e7acad13307bccbb017a073b695b6089a2c484f929e20e" +checksum = "580c875845b41ecb1564ad1ae4703044cef774145e3d7a94dc2b3fa7c97868fd" dependencies = [ "bitflags 2.13.2", ] @@ -3916,9 +3938,9 @@ dependencies = [ [[package]] name = "rustls" -version = "0.23.45" +version = "0.23.46" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0d41d731c7d2f962d1ccc364cec258de3c0e93b38c2fb3ba97ac74513048d634" +checksum = "48e13bd8c0e9365c43cfa5c9e8f9ad49d3c8444926c9aac819e0e4dc503c8fdf" dependencies = [ "log", "once_cell", @@ -3940,9 +3962,9 @@ dependencies = [ [[package]] name = "rustls-webpki" -version = "0.103.15" +version = "0.103.16" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f3c3cf1d8b1e7d4927e2d154c3fcb02979afb9939629c62cd9048d4f07b60ac2" +checksum = "4443b1b6d39d7c663139942cc4558262562d2c84123bb6282ef177009ca8b281" dependencies = [ "ring", "rustls-pki-types", @@ -4077,14 +4099,14 @@ checksum = "e7a5d71263a5a7d47b41f6b3f06ba276f10cc18b0931f1799f710578e2309348" dependencies = [ "proc-macro2", "quote", - "syn 3.0.6", + "syn 3.0.9", ] [[package]] name = "serde_json" -version = "1.0.151" +version = "1.0.154" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c841b55ecdae098c80dcae9cf767f6f8a0c2cdb3416bbef72181df4d0fe73f14" +checksum = "e7e9cc8b1b85264074fbcc02a88680c4096b1e47df8f739dceb03bf482f04bd6" dependencies = [ "itoa", "memchr", @@ -4101,7 +4123,7 @@ checksum = "8d3b1629de253c70a0508c3899572da79ca359fdab27c7920ff00406df418906" dependencies = [ "proc-macro2", "quote", - "syn 3.0.6", + "syn 3.0.9", ] [[package]] @@ -4235,9 +4257,9 @@ dependencies = [ [[package]] name = "smallvec" -version = "1.16.2" +version = "1.16.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f9395f0f0eee849a9b707b2f06bb92a6a422090e2123bb2ef8e87a0e61892a8e" +checksum = "5b3dc8af474f516a851ff4bd12db780f948b9250ad37211e4eec0bccea54e01b" dependencies = [ "serde", ] @@ -4401,9 +4423,9 @@ dependencies = [ [[package]] name = "syn" -version = "3.0.6" +version = "3.0.9" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8593e8e72159ed2257d083c7a454a85cbf854f37a0966d8d483aff8c8a3ebcee" +checksum = "d78c8dee4c7bf0e14673097256fed6142ce9d3b85a408189d07482442145823b" dependencies = [ "proc-macro2", "quote", @@ -4418,7 +4440,7 @@ checksum = "901704edd0dfe137f1987838ee4f259e4e063c31371bdb423f7ae38ec6f77f02" dependencies = [ "proc-macro2", "quote", - "syn 3.0.6", + "syn 3.0.9", ] [[package]] @@ -4480,7 +4502,7 @@ checksum = "fe5197923287db20a58125f0bc85c062f7f2c892de97b18c356f9efb14b28524" dependencies = [ "proc-macro2", "quote", - "syn 3.0.6", + "syn 3.0.9", ] [[package]] @@ -4566,9 +4588,9 @@ checksum = "fd3ca314f692efd6c868f8408f53fe444634a845f96c028b97d35f6a1f79f0ee" [[package]] name = "tokio" -version = "1.53.2" +version = "1.54.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e95f91fcc7a621e8b030f6aa23c71fe9838ae2fb4d8118b75602a328f5144044" +checksum = "a0c168a474b95bfecfe908a65d4bdffc523144b649a74c09c286afb35d53e0bc" dependencies = [ "pin-project-lite", ] @@ -4687,7 +4709,7 @@ dependencies = [ "http", "httparse", "log", - "rand 0.10.3", + "rand", "sha1", "thiserror 2.0.21", ] @@ -4750,9 +4772,9 @@ checksum = "0b993bddc193ae5bd0d623b49ec06ac3e9312875fdae725a975c51db1cc1677f" [[package]] name = "unicode-ident" -version = "1.0.26" +version = "1.0.27" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d245f478577f809a851594d02313b640fb437e0bb33866753cff937863096954" +checksum = "a2c754d6c33795a1c324727428e5a7dedb5b06195f9890bdbcba760d3e246563" [[package]] name = "unicode-properties" @@ -4844,9 +4866,9 @@ checksum = "06abde3611657adf66d383f00b093d7faecc7fa57071cce2578660c9f1010821" [[package]] name = "uuid" -version = "1.27.0" +version = "1.28.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "97277d36b9c3ace13e58fa6e753f8b0bbbf302a18dd193240d70f9e29681059a" +checksum = "7cc1186384beb7dd8eedea376413fd654937285ea6c9cfbb928dc3043ea4b606" dependencies = [ "js-sys", "serde_core", @@ -4963,7 +4985,7 @@ dependencies = [ "bumpalo", "proc-macro2", "quote", - "syn 3.0.6", + "syn 3.0.9", "wasm-bindgen-shared", ] @@ -4978,12 +5000,12 @@ dependencies = [ [[package]] name = "wasm-encoder" -version = "0.261.0" +version = "0.262.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2608e8bb6d67fd68f5a8d0eb1363d6e7bcbc1f8ded5a0bd3a1e382462b876b22" +checksum = "4495a6f85dd94c411c749303efcbc5defade0b0cbdbc0541495241ab917a9b5a" dependencies = [ "leb128fmt", - "wasmparser 0.261.0", + "wasmparser 0.262.0", ] [[package]] @@ -5038,9 +5060,9 @@ dependencies = [ [[package]] name = "wasmparser" -version = "0.261.0" +version = "0.262.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4f20f20e44f7e8aeb6744823ea9d869ede51e51be4fdaedede2852282e54d2d8" +checksum = "974d643b125b272607e2fff8012ea14a8328acd7621e4bafa7afb32500052b1b" dependencies = [ "bitflags 2.13.2", "indexmap", @@ -5049,9 +5071,9 @@ dependencies = [ [[package]] name = "wast" -version = "261.0.0" +version = "262.0.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "776443145731a4062e5b0d392892a2005909b6ab72d9fdc3cad53dd1a714e44a" +checksum = "30612a0c29249b5f185888a107e8a519d920226b7d07f9d351fd094e9f75eb23" dependencies = [ "bumpalo", "leb128fmt", @@ -5062,9 +5084,9 @@ dependencies = [ [[package]] name = "wat" -version = "1.261.0" +version = "1.262.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a7b4d1a49ea73a8f3326e74e3a05db667001b16bd1035ed3356fc1a0ed05ca7f" +checksum = "e228027f2b07bbaf946980ebbe2437fca73be6f54c40c2a8d4f42f277075f8d1" dependencies = [ "wast", ] @@ -5242,9 +5264,9 @@ dependencies = [ [[package]] name = "webpki-roots" -version = "1.0.9" +version = "1.0.10" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7dcd9d09a39985f5344844e66b0c530a33843579125f23e21e9f0f220850f22a" +checksum = "1c1c8e20c8a38fc7d65abfe74eb7280e4aa667e8eec8bc84996d2874810748d9" dependencies = [ "rustls-pki-types", ] @@ -5575,7 +5597,7 @@ version = "0.52.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "282be5f36a8ce781fad8c8ae18fa3f9beff57ec1b52cb3de0789201425d9a33d" dependencies = [ - "windows-targets 0.52.6", + "windows-targets", ] [[package]] @@ -5584,16 +5606,7 @@ version = "0.59.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1e38bc4d79ed67fd075bcc251a1c39b32a1776bbe92e5bef1f0bf1f8c531853b" dependencies = [ - "windows-targets 0.52.6", -] - -[[package]] -name = "windows-sys" -version = "0.60.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f2f500e4d28234f72040990ec9d39e3a6b950f9f22d3dba18416c35882612bcb" -dependencies = [ - "windows-targets 0.53.5", + "windows-targets", ] [[package]] @@ -5611,31 +5624,14 @@ version = "0.52.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9b724f72796e036ab90c1021d4780d4d3d648aca59e491e6b98e725b84e99973" dependencies = [ - "windows_aarch64_gnullvm 0.52.6", - "windows_aarch64_msvc 0.52.6", - "windows_i686_gnu 0.52.6", - "windows_i686_gnullvm 0.52.6", - "windows_i686_msvc 0.52.6", - "windows_x86_64_gnu 0.52.6", - "windows_x86_64_gnullvm 0.52.6", - "windows_x86_64_msvc 0.52.6", -] - -[[package]] -name = "windows-targets" -version = "0.53.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4945f9f551b88e0d65f3db0bc25c33b8acea4d9e41163edf90dcd0b19f9069f3" -dependencies = [ - "windows-link", - "windows_aarch64_gnullvm 0.53.1", - "windows_aarch64_msvc 0.53.1", - "windows_i686_gnu 0.53.1", - "windows_i686_gnullvm 0.53.1", - "windows_i686_msvc 0.53.1", - "windows_x86_64_gnu 0.53.1", - "windows_x86_64_gnullvm 0.53.1", - "windows_x86_64_msvc 0.53.1", + "windows_aarch64_gnullvm", + "windows_aarch64_msvc", + "windows_i686_gnu", + "windows_i686_gnullvm", + "windows_i686_msvc", + "windows_x86_64_gnu", + "windows_x86_64_gnullvm", + "windows_x86_64_msvc", ] [[package]] @@ -5653,96 +5649,48 @@ version = "0.52.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "32a4622180e7a0ec044bb555404c800bc9fd9ec262ec147edd5989ccd0c02cd3" -[[package]] -name = "windows_aarch64_gnullvm" -version = "0.53.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a9d8416fa8b42f5c947f8482c43e7d89e73a173cead56d044f6a56104a6d1b53" - [[package]] name = "windows_aarch64_msvc" version = "0.52.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "09ec2a7bb152e2252b53fa7803150007879548bc709c039df7627cabbd05d469" -[[package]] -name = "windows_aarch64_msvc" -version = "0.53.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b9d782e804c2f632e395708e99a94275910eb9100b2114651e04744e9b125006" - [[package]] name = "windows_i686_gnu" version = "0.52.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "8e9b5ad5ab802e97eb8e295ac6720e509ee4c243f69d781394014ebfe8bbfa0b" -[[package]] -name = "windows_i686_gnu" -version = "0.53.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "960e6da069d81e09becb0ca57a65220ddff016ff2d6af6a223cf372a506593a3" - [[package]] name = "windows_i686_gnullvm" version = "0.52.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0eee52d38c090b3caa76c563b86c3a4bd71ef1a819287c19d586d7334ae8ed66" -[[package]] -name = "windows_i686_gnullvm" -version = "0.53.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "fa7359d10048f68ab8b09fa71c3daccfb0e9b559aed648a8f95469c27057180c" - [[package]] name = "windows_i686_msvc" version = "0.52.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "240948bc05c5e7c6dabba28bf89d89ffce3e303022809e73deaefe4f6ec56c66" -[[package]] -name = "windows_i686_msvc" -version = "0.53.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1e7ac75179f18232fe9c285163565a57ef8d3c89254a30685b57d83a38d326c2" - [[package]] name = "windows_x86_64_gnu" version = "0.52.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "147a5c80aabfbf0c7d901cb5895d1de30ef2907eb21fbbab29ca94c5b08b1a78" -[[package]] -name = "windows_x86_64_gnu" -version = "0.53.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9c3842cdd74a865a8066ab39c8a7a473c0778a3f29370b5fd6b4b9aa7df4a499" - [[package]] name = "windows_x86_64_gnullvm" version = "0.52.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "24d5b23dc417412679681396f2b49f3de8c1473deb516bd34410872eff51ed0d" -[[package]] -name = "windows_x86_64_gnullvm" -version = "0.53.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0ffa179e2d07eee8ad8f57493436566c7cc30ac536a3379fdf008f47f6bb7ae1" - [[package]] name = "windows_x86_64_msvc" version = "0.52.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "589f6da84c646204747d1270a2a5661ea66ed1cced2631d546fdfb155959f9ec" -[[package]] -name = "windows_x86_64_msvc" -version = "0.53.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d6bbff5f0aada427a1e5a6da5f1f98158182f26556f345ac9e04d36d0ebed650" - [[package]] name = "winit" version = "0.30.13" @@ -5806,9 +5754,9 @@ dependencies = [ [[package]] name = "winresource" -version = "0.1.31" +version = "0.1.34" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0986a8b1d586b7d3e4fe3d9ea39fb451ae22869dcea4aa109d287a374d866087" +checksum = "a2bc6d36480b829272a5bdabf08e6dca8004761c1da7e35efef800540c66221b" dependencies = [ "toml", "version_check", @@ -5934,7 +5882,7 @@ checksum = "9440ea3e5aeabb0ac63af70daf835274065238cdd0cec83418f417eae38bacee" [[package]] name = "xtask" -version = "0.7.0" +version = "0.10.0" dependencies = [ "figcraft-engine", "serde_json", @@ -5959,7 +5907,7 @@ checksum = "ec8ebde2db3681e8c9980cc27822030e68752690ddfa9473e739aeb4dbde6d71" dependencies = [ "proc-macro2", "quote", - "syn 3.0.6", + "syn 3.0.9", "synstructure", ] @@ -6031,7 +5979,7 @@ dependencies = [ "proc-macro-crate", "proc-macro2", "quote", - "syn 3.0.6", + "syn 3.0.9", "zbus_names", "zvariant", "zvariant_utils", @@ -6039,9 +5987,9 @@ dependencies = [ [[package]] name = "zbus_names" -version = "4.3.4" +version = "4.3.5" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d8bf88b4a3ff53e883001e0e0115b297a9d53c31b9c1edd2bfdd853e3428624e" +checksum = "812a12be4ead7a59e18ea8f047d08add0066b73bc62fc1467066a2472ae088dd" dependencies = [ "serde", "winnow", @@ -6071,18 +6019,18 @@ dependencies = [ [[package]] name = "zerocopy" -version = "0.8.62" +version = "0.8.63" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "86502bf56ac7c77571a32e2647bb2a15894565e981fb2a48d7bde2d91c965a9d" +checksum = "e5fe1f8f1b06191a00962174c61aa5005e0bb391a6d80d07e24d115c01a92ed8" dependencies = [ "zerocopy-derive", ] [[package]] name = "zerocopy-derive" -version = "0.8.62" +version = "0.8.63" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5457206954b06561e2608c7e19cf58b1926586d999c246eebe4502f7e2039d1a" +checksum = "863ad3ac83293fb4d740aedbfdc9240dd8d1a50c1099acd76ce80ce7c7230c7f" dependencies = [ "proc-macro2", "quote", @@ -6106,7 +6054,7 @@ checksum = "f75b4683f6c7f45248d4d64056a24298c6281e0993356d7d1b4a1a962ef10d4a" dependencies = [ "proc-macro2", "quote", - "syn 3.0.6", + "syn 3.0.9", "synstructure", ] @@ -6146,23 +6094,21 @@ checksum = "34df6fc39dbd26ddc9c10e6a2984476e13acce22e64e4487636ef494369225da" dependencies = [ "proc-macro2", "quote", - "syn 3.0.6", + "syn 3.0.9", ] [[package]] name = "zip" -version = "2.4.2" +version = "2.6.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "fabe6324e908f85a1c52063ce7aa26b68dcb7eb6dbc83a2d148403c9bc3eba50" +checksum = "1dcb24d0152526ae49b9b96c1dcf71850ca1e0b882e4e28ed898a93c41334744" dependencies = [ "arbitrary", "crc32fast", "crossbeam-utils", - "displaydoc", "flate2", "indexmap", "memchr", - "thiserror 2.0.21", "zopfli", ] @@ -6180,9 +6126,9 @@ checksum = "29666d0abbfad1e3dc4dcf6144730dd3a3ab225bbbdac83319345b1b44ccfc1b" [[package]] name = "zopfli" -version = "0.8.3" +version = "0.8.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f05cd8797d63865425ff89b5c4a48804f35ba0ce8d125800027ad6017d2b5249" +checksum = "aaf7fc5d30c28483d93805c4a5e12b05bbb52407fa67c5f8bd552374cd01fb11" dependencies = [ "bumpalo", "crc32fast", @@ -6207,9 +6153,9 @@ dependencies = [ [[package]] name = "zvariant" -version = "5.15.0" +version = "5.15.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c1d34c27cc6cdd1f458427519dd6b8612f7b7e3f7b9a0b2355d041dda9869147" +checksum = "80bf6d6cd6a4b601acb64cfb8284cdc643fcfa75ef26ae3c46ec8aa4e8a1fbb7" dependencies = [ "endi", "enumflags2", @@ -6222,14 +6168,14 @@ dependencies = [ [[package]] name = "zvariant_derive" -version = "5.15.0" +version = "5.15.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "864155e69b4352db0c7f374917bf45d1e0c8d17659c8b3dbf9795f3673f8c497" +checksum = "ef2672456ba1cb54b5eaec136e63135b3c7495fb938e53b28a62e633496d486d" dependencies = [ "proc-macro-crate", "proc-macro2", "quote", - "syn 3.0.6", + "syn 3.0.9", "zvariant_utils", ] @@ -6242,6 +6188,6 @@ dependencies = [ "proc-macro2", "quote", "serde", - "syn 3.0.6", + "syn 3.0.9", "winnow", ] diff --git a/Cargo.toml b/Cargo.toml index 932da46f..90297f29 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -6,7 +6,7 @@ exclude = ["plugins"] # default-members = ["crates/*", "apps/figcraft", "apps/figcraft-cli", "xtask"] [workspace.package] -version = "0.7.0" +version = "0.10.0" edition = "2024" license = "MIT OR Apache-2.0" rust-version = "1.95" @@ -34,6 +34,9 @@ figcraft-plugin = { path = "crates/plugin" } # Which paths stored in a document may be followed (craft-libs). craft-docpath = { version = "0.1.0", git = "https://github.com/storytold/craft-libs", rev = "82a24b006eefa036b43c09a67fb9a3fbd17ffe63" } +# Who draws the window frame on Linux — the compositor or the app — so every Craft app shows the +# same native titlebar per desktop (craft-libs). The `egui` feature adds `decorate`/`decorations`. +craft-window = { git = "https://github.com/storytold/craft-libs", rev = "1f805378d454dac48b5c214029d254055fdf961b", features = ["egui"] } # foundation kurbo = { version = "0.13", features = ["serde"] } @@ -42,6 +45,8 @@ serde = { version = "1", features = ["derive", "rc"] } serde_json = { version = "1", features = ["float_roundtrip"] } regex = "1.13" log = "0.4" +# All date/time conversion goes through chrono (no hand-rolled calendar code beyond the 400-year range extension; AGENTS.md). +chrono = { version = "0.4.45", default-features = false, features = ["std"] } proptest = "1" imbl = { version = "7", features = ["serde"] } postcard = { version = "1", default-features = false, features = ["alloc"] } @@ -98,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" diff --git a/README.md b/README.md index a072a06f..ebbf7fa0 100644 --- a/README.md +++ b/README.md @@ -7,10 +7,14 @@

+

+ FigCraft icon: an engraved chameleon on a mint-green tile +

+

FigCraft

- Interface design; an open-source, clean-room reimplementation of Figma, rebuilt in pure Rust.
+ Interface design and prototyping: open source, collaborative and built in pure Rust.
Frames, auto layout, components and variants, vector networks, styles, variables and prototypes,
in a native app that opens instantly and works offline. Open source, and yours.

@@ -20,6 +24,9 @@ macOS · Windows · Linux · FreeBSD · Web License: MIT OR Apache-2.0 Status: early alpha +
+ Scanned by SonarCloud + Reviewed by CodeRabbit

@@ -62,7 +69,7 @@

🎛️ Familiar by design

- The toolbar, layers panel, Design panel, menus and shortcuts sit where your hands expect them. If you know Figma, you already know FigCraft. + The toolbar, layers panel, Design panel, menus and shortcuts sit right where your hands expect them.

⚡ Native and fast

@@ -227,7 +234,7 @@ The desktop app also offers a loopback-only control channel (`figcraft --control - **Its own renderer:** tiny-skia on every core for the canvas and exports, with layer blending, effects and masks; booleans from linesweeper, geometry from kurbo, text from skrifa and HarfRust. - **Runs in the browser:** the whole engine and UI compile to WebAssembly. - **Accessible:** screen readers get names, roles, values and states for the tools, layer rows, fields, dropdowns, tabs and menus (AccessKit on desktop), F6 moves the keyboard between the toolbar, layers, canvas and properties (Tab within them, Escape back to the canvas), the system's reduce-motion setting is honoured, and panel text meets WCAG AA contrast in both themes. -- **Languages:** English, Russian and Turkish (reviewed by native speakers), and Spanish, French, German, Brazilian Portuguese, Japanese, Korean, Simplified Chinese, Czech and Indonesian drafts, picked in Preferences or taken from the system or browser and switched live; adding a language is one catalog file ([`docs/translating.md`](docs/translating.md)). +- **Languages:** English, Russian and Turkish (reviewed by native speakers), and Spanish, French, German, Brazilian Portuguese, Japanese, Korean, Simplified Chinese, Czech, Indonesian and Georgian drafts, picked in Preferences or taken from the system or browser and switched live; adding a language is one catalog file ([`docs/translating.md`](docs/translating.md)). - **Plugins:** sandboxed WebAssembly plugins (a metered, pure-Rust interpreter with a memory cap and no file or network access, in the browser too) that act through the same commands, each run one undo step, within the permissions their manifest asks for. A Rust SDK and three example plugins are in [`plugins/`](plugins); see [`docs/plugins.md`](docs/plugins.md). - **Live sessions (preview):** share the open file from the app (Share ▸ Start sharing: online through a server, or on your local network), give the others the short join code it shows (or the invite link), and edit it together: everyone's changes sync live, with cursors, selections, avatars and follow, and undo takes back only your own changes. Others join from the desktop app (invite links open it) or from the browser build. See [`docs/collab-protocol.md`](docs/collab-protocol.md). - **A server of your own (preview):** `figcraft-server` keeps a team's files, publishes them from the app (File ▸ Publish to server…, or Share ▸ Online with your server in Preferences) and hands out share links and join codes that let people edit or only view; files survive restarts and apps reconnect where they were. No accounts yet: links are the access. Releases ship it for Linux, and the desktop app joins over `wss://`; [hosting it on a rented Linux server](docs/server.md#deploy-on-a-linux-server) takes a systemd unit and Caddy or nginx (or Docker). See [`docs/server.md`](docs/server.md). @@ -262,7 +269,7 @@ New contributors and AI agents: start with [`AGENTS.md`](AGENTS.md), then [`docs Installers for macOS (universal, signed and notarized), Windows (x64, x86 and arm64), Linux (AppImage, `.deb`, `.rpm`, Flatpak and tarball), FreeBSD and the web are attached to each [GitHub release](https://github.com/storytold/figcraft/releases). Maintainers: [`docs/releasing.md`](docs/releasing.md) explains how releases are built, signed and published. > [!IMPORTANT] -> **Status:** FigCraft is in **alpha**: about 49% of the way to replacing Figma for real work, by our own estimate. Nearly all of Figma's main menu is wired ([`docs/parity-checklist.md`](docs/parity-checklist.md)) and the core editor works end to end, but FigCraft opens `.fig` files only best effort, has only a first preview of multiplayer (live sessions hosted from the app or a self-hosted server, with per-person undo and share links to edit or to view; no accounts, teams or cloud yet), runs its own WebAssembly plugins rather than Figma's, and hasn't been checked against real Figma documents. The honest, area-by-area picture is in the [roadmap](ROADMAP.md) and [`docs/target-app-parity.md`](docs/target-app-parity.md). Expect rough edges and please file issues, or tell us what broke on [Discord](https://discord.gg/artcraft). +> **Status:** FigCraft is in **alpha**: about 49% of the way to replacing proprietary tools for real work, by our own estimate. Nearly all tracked interface design actions in the menu checklist are wired ([`docs/parity-checklist.md`](docs/parity-checklist.md)) and the core editor works end to end, but FigCraft opens `.fig` files only best effort, has only a first preview of multiplayer (live sessions hosted from the app or a self-hosted server, with per-person undo and share links to edit or to view; no accounts, teams or cloud yet), runs its own WebAssembly plugins, and hasn't been checked against real design documents. The honest, area-by-area picture is in the [roadmap](ROADMAP.md) and [`docs/target-app-parity.md`](docs/target-app-parity.md). Expect rough edges and please file issues, or tell us what broke on [Discord](https://discord.gg/artcraft). ## Privacy and online sharing @@ -348,8 +355,6 @@ ArtCraft Team and are not covered by this license. They may be used only unmodif part of this repository and FigCraft, under [`docs/brand/LICENSE-brand.txt`](docs/brand/LICENSE-brand.txt). Forks and modified versions must remove them. -Figma is a trademark of Figma, Inc. FigCraft is an independent, open-source project and is not affiliated with, sponsored by or endorsed by Figma, Inc.; the name is used only to describe the workflows FigCraft is compatible with. -

ArtCraft
Made by the ArtCraft team and community. diff --git a/ROADMAP.md b/ROADMAP.md index f3c4bb14..d32eb906 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 (Outline stroke preserves stroke holes; 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-22 · **Change:** minor (boards: a board area row and progress entry; `fill`/`stroke` props accept `"none"` and `null` as no paint (#1036); 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) 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,16 +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** · M4: Outline stroke preserves even-odd stroke holes. Pixel comparisons cover alignment, dashes, individual side weights, vector paths and stroke-only opacity; native save/reopen, Undo/Redo and instance opacity inheritance/overrides are covered by `outline_stroke`. Existing estimates unchanged; filled-shape compositing needs separate work. - +- **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. @@ -318,6 +328,64 @@ 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 | +| 2026-10-20 | minor | Layer data reads back (#879): `node.get` (the command, the control method and MCP `get_node`) and `document.inspect` report a layer's or page's `pluginData` as `{key: value}` when it has any, so a tool that keeps state in a file with `node.set {props: {pluginData}}` can read it on its next run (`plugin_data_tests`). Numbers unchanged | +| 2026-10-20 | minor | Measuring while nudging (#894): with Alt held over another layer, the arrows nudge the selection (Shift: the big step) and the distances follow every nudge (Alt+arrows used to do nothing); while measuring, the selection's handles and size label hide and both layers keep their outlines (`measure_nudge_tests`). Numbers unchanged | +| 2026-10-20 | minor | Release fonts (#885): the web release embeds craft-fonts again (Inter), so the browser version draws text and lists fonts; it had none since releases moved to the shared workflow. The craft-fonts pin goes back to the commit with the Latin manifest (the Georgian change had moved it to one without Inter, which would have failed the next release). Numbers unchanged. | | 2026-10-17 | minor | Community batch #753 (10 PRs): Selection colors for mixed selections (#403); smart-selection spacing fields and canvas handles (#402); a Present tab with device and scale controls (#537); adding auto layout to an empty frame keeps its size (#369); `node.set` applies auto layout keys to frames only and warns about font styles a family lacks (#603); canvas images on whole device pixels (#359); Adwaita window controls on Wayland without server decorations (#592, not run on GNOME or KDE by the maintainers); the keyboard shortcuts panel closes on the home screen (#548); macOS paste modifiers in tests (#750); credits column tests (#347). `cargo xtask parity` re-run: 212 / 212 menu items (100%; the catalog gained View ▸ Full screen with #227 on 2026-10-14), 561 commands. Language table re-measured from the catalogs (2,272 entries): the drafts read 95–99% (strings added since 2026-10-12 show English), Indonesian has a draft (99%; it read none), and Russian, Swedish (its command labels from #599), Turkish and Czech are listed with their counts. Per-area parity rows for the day's landings in `docs/gaps.md`, `target-app-parity.md` and the area docs. Numbers unchanged | | 2026-10-17 | minor | 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 | minor | Community fixes from #524 up (batches #727, #728, #733, #735, #736 and #729; 55 PRs) and a crash guard. `.fig` import: explicit instance identity, overrides by key, text style precedence, grid track sizes, slot content, connector labels, skip ink, paragraph options and hanging lists, list start numbers, vertical trim, per-point stroke joins, text on a path, image adjustments and embedded images; large files open within budgets from the file's own size; an import report after opening; imported bytes stay out of the command journal. Files and paste: save a copy as `.fig`; Cmd/Ctrl+V pastes images copied in other apps and layers copied from another design app (`edit.pasteDesignLayers`); paste over and paste properties; clipboard reads on Linux and the BSDs time out after 2 s. Editor: live font preview on hover; Shift+I and Resources open Assets; Shift-drag moves the selection; thin strokes take a nearby double-click; dropped points merge; Flatten keeps open paths; the Actions palette gets tabs, ranked search, a keyboard highlight and recents; the colour picker opens on a new fill or stroke; text strokes follow their alignment; selection highlights after line breaks; a draggable pages/layers divider; variant properties reorder by drag; submenus keep their hover with a safe triangle; menu actions that need a value ask for it; File ▸ Rename edits the name in place. Engine: bad paint indexes, non-finite geometry and pages in copy/cut are refused; a param naming a layer says so when it isn't one; a CLI script is one undo step and resolves `$step`. Server and collaboration: message limits follow `max_file_mb` (client capped at 1 GiB); a damaged generation field no longer drops flushed edits; the web build joins a session from a `#join=` link. Also Swedish command labels, Nix CI through the shared workflow, paint bounds once per render, and a guard that turns a panic inside the boolean-op library into bad input (the stroke draws unclipped). Numbers unchanged. | @@ -325,9 +393,9 @@ Ranked; detail and milestones in [`docs/roadmap.md`](docs/roadmap.md). | 2026-10-17 | minor | 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-16 | minor | Community fixes from #478 up (batches #591, #598, #602; 69 PRs) and a control-channel fix: a line that isn't JSON now ends a `--control` connection, so a web page can't drive the app through a browser request (#589). Highlights: page copies keep navigation, flows, prototype settings and guides; Find and Replace keeps line endings and case-insensitive matches; text hyperlinks survive import and export; recent colours record only what was picked; MCP screenshots respect `maxSize`; Swedish catalog (UI strings), Czech plurals; bounded canvas overscan while panning; Korean fallback at the run's weight; many vector-edit, guide, page and .fig round-trip fixes. Numbers unchanged | | 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 | M4: Outline stroke preserves even-odd stroke holes. Pixel comparisons cover alignment, dashes, individual side weights, vector paths and stroke-only opacity; native save/reopen, Undo/Redo and instance opacity inheritance/overrides are covered by `outline_stroke`. Existing estimates unchanged; filled-shape compositing needs separate work. | | 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 65b19ee4..b3778063 100644 --- a/apps/figcraft-cli/src/files.rs +++ b/apps/figcraft-cli/src/files.rs @@ -29,9 +29,20 @@ fn image_format(e: &str) -> Option { } } +/// Writes through [`figcraft_engine::file_access`] when automation roots are in force, so a +/// confined run can only write inside a write root (#833). +/// +/// Unconfined it writes with `std::fs::write`, as it always did. `file_access::write` stages the +/// bytes and renames them into place, which **replaces** a symbolic link at `path` instead of +/// writing through it to its target -- a change of behaviour for anyone whose output path is a +/// link, and nothing to do with confinement. Inside a root the staged write is the right one: +/// `Roots::check` has already refused a link that leads out of the root. fn write(path: &str, bytes: &[u8]) -> CliResult<()> { if let Some(dir) = Path::new(path).parent().filter(|d| !d.as_os_str().is_empty()) { - std::fs::create_dir_all(dir).map_err(|e| CliError::Failed(format!("{}: {e}", dir.display())))?; + figcraft_engine::file_access::create_dir_all(dir).map_err(|e| CliError::Failed(e.to_string()))?; + } + if figcraft_engine::file_access::current().is_some() { + return figcraft_engine::file_access::write(path, bytes).map_err(|e| CliError::Failed(e.to_string())); } std::fs::write(path, bytes).map_err(|e| CliError::Failed(format!("{path}: {e}"))) } @@ -196,9 +207,11 @@ 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. 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(); + let s = figcraft_engine::cmd::file::safe_file_name(name); + let s = s.trim_matches(|c: char| c == '.' || c.is_whitespace()).to_string(); if s.is_empty() { "layer".into() } else { s.chars().take(100).collect() } } @@ -244,7 +257,7 @@ pub fn export(file: &str, out: &str, t: &Target) -> CliResult { println!("{out}"); return Ok(ExitCode::SUCCESS); } - std::fs::create_dir_all(out).map_err(|e| CliError::Failed(format!("{out}: {e}")))?; + figcraft_engine::file_access::create_dir_all(out).map_err(|e| CliError::Failed(e.to_string()))?; let ids = match target { Some(t) => vec![t], None => { @@ -329,7 +342,8 @@ pub fn parse_script(text: &str) -> Result, String> { } pub fn run_script(script: &str, input: Option<&str>, out: Option<&str>) -> CliResult { - let text = std::fs::read_to_string(script).map_err(|e| CliError::Failed(format!("{script}: {e}")))?; + let bytes = figcraft_engine::file_access::read(script).map_err(|e| CliError::Failed(e.to_string()))?; + let text = String::from_utf8(bytes).map_err(|_| CliError::Failed(format!("{script}: not UTF-8")))?; let steps = parse_script(&text).map_err(|e| CliError::Failed(format!("{script}: {e}")))?; let mut s = match input { Some(path) => { @@ -399,6 +413,13 @@ 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); } #[test] diff --git a/apps/figcraft-cli/src/main.rs b/apps/figcraft-cli/src/main.rs index 281d79dd..66627673 100644 --- a/apps/figcraft-cli/src/main.rs +++ b/apps/figcraft-cli/src/main.rs @@ -22,11 +22,12 @@ USAGE: figcraft-cli convert figcraft-cli render -o [--scale N] [--page NAME] figcraft-cli run [--in ] [--out ] + (info, export, convert, render and run also take --automation-read-root DIR / --automation-write-root DIR) figcraft-cli commands [--json] [--filter TEXT] 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 @@ -42,16 +43,42 @@ crop and bleed marks, CMYK colour). `run` executes a JSON array (or JSON lines) `plugin publish` sends a plugin folder (figcraft-plugin.json, its module, an optional assets/ folder) to a figcraft-server's plugin directory as a new version; the server's key comes from the address or the FIGCRAFT_SERVER_KEY environment variable (which keeps it out of the shell history). -`mcp --automation-read-root DIR` / `--automation-write-root DIR` (each may be repeated; the folders -must exist) confine the files the agent's commands touch: reads only inside a read or write root, -writes only inside a write root, everything else refused. A running app confines itself: start it -with `figcraft --control PORT` and the same flags (`--attach` refuses them). +`--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 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 +/// unconfined, as before; giving either confines every file the subcommand touches (#833). +const ROOT_FLAGS: [&str; 2] = ["automation-read-root", "automation-write-root"]; + +fn roots_of(a: &Args) -> CliResult> { + let (read, write) = (a.values("automation-read-root"), a.values("automation-write-root")); + if read.is_empty() && write.is_empty() { + return Ok(None); + } + Ok(Some(figcraft_engine::file_access::Roots::new(read, write).map_err(CliError::Failed)?)) +} + +/// Runs `f` confined to the roots `a` carries. +fn confined(a: &Args, f: impl FnOnce() -> CliResult) -> CliResult { + let roots = roots_of(a)?.map(std::sync::Arc::new); + figcraft_engine::file_access::confined(roots.as_ref(), f) +} + 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) @@ -65,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. @@ -98,35 +127,38 @@ fn run(argv: &[String]) -> CliResult { Ok(ExitCode::SUCCESS) } "info" => { - let a = Args::parse(rest, &[], &["json"])?; + let a = Args::parse(rest, &ROOT_FLAGS, &["json"])?; let file = a.one_positional("info ")?; - files::info(&file, a.flag("json")) + confined(&a, || files::info(&file, a.flag("json"))) } "export" => { - let a = Args::parse(rest, &["format", "scale", "page", "node", "o", "out", "bleed"], &["crop-marks", "bleed-marks", "cmyk"])?; + let valued = [&["format", "scale", "page", "node", "o", "out", "bleed"][..], &ROOT_FLAGS[..]].concat(); + let a = Args::parse(rest, &valued, &["crop-marks", "bleed-marks", "cmyk"])?; let file = a.one_positional("export -o ")?; let out = a.value("o").or_else(|| a.value("out")).ok_or_else(|| CliError::Usage("export needs -o ".into()))?; let target = files::Target { format: a.value("format"), scale: a.scale()?, page: a.value("page"), node: a.value("node"), print: a.print()? }; - files::export(&file, &out, &target) + confined(&a, || files::export(&file, &out, &target)) } "convert" => { - let a = Args::parse(rest, &[], &[])?; + let a = Args::parse(rest, &ROOT_FLAGS, &[])?; match a.positional.as_slice() { - [i, o] => files::convert(i, o), + [i, o] => confined(&a, || files::convert(i, o)), _ => Err(CliError::Usage("convert ".into())), } } "render" => { - let a = Args::parse(rest, &["o", "out", "scale", "page"], &[])?; + let valued = [&["o", "out", "scale", "page"][..], &ROOT_FLAGS[..]].concat(); + let a = Args::parse(rest, &valued, &[])?; let file = a.one_positional("render -o ")?; let out = a.value("o").or_else(|| a.value("out")).ok_or_else(|| CliError::Usage("render needs -o ".into()))?; - files::render(&file, &out, a.scale()?, a.value("page").as_deref()) + confined(&a, || files::render(&file, &out, a.scale()?, a.value("page").as_deref())) } "run" => { - let a = Args::parse(rest, &["in", "out"], &[])?; + let valued = [&["in", "out"][..], &ROOT_FLAGS[..]].concat(); + let a = Args::parse(rest, &valued, &[])?; let script = a.one_positional("run ")?; - files::run_script(&script, a.value("in").as_deref(), a.value("out").as_deref()) + confined(&a, || files::run_script(&script, a.value("in").as_deref(), a.value("out").as_deref())) } "commands" => { let a = Args::parse(rest, &["filter"], &["json"])?; @@ -137,23 +169,20 @@ fn run(argv: &[String]) -> CliResult { Ok(ExitCode::SUCCESS) } "mcp" => { - let a = Args::parse(rest, &["attach", "automation-read-root", "automation-write-root"], &[])?; + let valued = [&["attach"][..], &ROOT_FLAGS[..]].concat(); + let a = Args::parse(rest, &valued, &[])?; if !a.positional.is_empty() { return Err(CliError::Usage("mcp takes no positional arguments".into())); } - let (read, write) = (a.values("automation-read-root"), a.values("automation-write-root")); - let roots = match (read.is_empty() && write.is_empty(), a.value("attach")) { - (true, _) => None, - // The app runs the commands: only its own roots can confine them. - (false, Some(_)) => { - return Err(CliError::Usage( - "--attach can't take automation roots: start the app with them instead (`figcraft --control PORT --automation-read-root DIR ...`)" - .into(), - )); - } - (false, None) => Some(figcraft_engine::file_access::Roots::new(read, write).map_err(CliError::Failed)?), - }; - mcp(a.value("attach").as_deref(), roots) + let attach = a.value("attach"); + // The app runs the commands: only its own roots can confine them. + if attach.is_some() && ROOT_FLAGS.iter().any(|f| !a.values(f).is_empty()) { + return Err(CliError::Usage( + "--attach can't take automation roots: start the app with them instead (`figcraft --control PORT --automation-read-root DIR ...`)" + .into(), + )); + } + mcp(attach.as_deref(), roots_of(&a)?) } "plugin" => { let a = Args::parse(rest, &["server"], &[])?; diff --git a/apps/figcraft-cli/tests/cli.rs b/apps/figcraft-cli/tests/it/cli.rs similarity index 52% rename from apps/figcraft-cli/tests/cli.rs rename to apps/figcraft-cli/tests/it/cli.rs index 7eaf051f..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". @@ -172,3 +229,98 @@ fn commands_json_lists_everything() { let v: serde_json::Value = serde_json::from_slice(&out.stdout).unwrap(); assert!(v.as_array().unwrap().len() > 200); } + +/// The automation roots confine the file subcommands too, not just `mcp` (#833): a read outside a +/// root, and a write outside a write root, are refused and leave no file behind. Without the flags +/// the same commands behave as they always did. +#[test] +fn file_subcommands_are_confined_to_automation_roots() { + let d = tmp("file-roots"); + let _ = std::fs::remove_dir_all(&d); + for sub in ["in", "out"] { + std::fs::create_dir_all(d.join(sub)).unwrap(); + } + let p = |rel: &str| d.join(rel).to_string_lossy().into_owned(); + let out_of = |o: &std::process::Output| String::from_utf8_lossy(&o.stderr).into_owned(); + + // A document to work on, made unconfined. + std::fs::write(d.join("in/make.json"), br#"[{"command":"shape.rectangle","params":{"x":0,"y":0,"width":40,"height":20}}]"#).unwrap(); + let made = bin().args(["run", &p("in/make.json"), "--out", &p("in/doc.figcraft")]).output().unwrap(); + assert!(made.status.success(), "{}", out_of(&made)); + + // Inside the roots: renders as usual. + let ok = bin() + .args(["render", &p("in/doc.figcraft"), "-o", &p("out/ok.png"), "--automation-read-root", &p("in"), "--automation-write-root", &p("out")]) + .output() + .unwrap(); + assert!(ok.status.success(), "{}", out_of(&ok)); + assert!(d.join("out/ok.png").is_file()); + + // A write outside the write root is refused, and nothing is written. + let escaped = d.join("escaped.png"); + let refused = bin() + .args([ + "render", + &p("in/doc.figcraft"), + "-o", + &escaped.to_string_lossy(), + "--automation-read-root", + &p("in"), + "--automation-write-root", + &p("out"), + ]) + .output() + .unwrap(); + assert!(!refused.status.success()); + assert!(out_of(&refused).contains("may be written"), "{}", out_of(&refused)); + assert!(!escaped.exists(), "the refused render wrote a file anyway"); + + // No write root at all: writing is refused outright, reading still works. + let no_write = bin().args(["render", &p("in/doc.figcraft"), "-o", &p("out/no.png"), "--automation-read-root", &p("in")]).output().unwrap(); + assert!(!no_write.status.success()); + assert!(out_of(&no_write).contains("no folder may be written"), "{}", out_of(&no_write)); + + // A read outside every root is refused: `info` on the document with only `out` readable. + let read_refused = bin().args(["info", &p("in/doc.figcraft"), "--automation-read-root", &p("out")]).output().unwrap(); + assert!(!read_refused.status.success()); + assert!(out_of(&read_refused).contains("may be read"), "{}", out_of(&read_refused)); + + // `convert` and `run` take them as well, and a root that is not a folder is refused. + let missing = bin().args(["convert", &p("in/doc.figcraft"), &p("out/c.svg"), "--automation-read-root", &p("missing")]).output().unwrap(); + assert!(!missing.status.success()); + assert!(out_of(&missing).contains("automation-read-root"), "{}", out_of(&missing)); + + // Unconfined, the same render still writes wherever it is told. + let plain = bin().args(["render", &p("in/doc.figcraft"), "-o", &escaped.to_string_lossy()]).output().unwrap(); + assert!(plain.status.success(), "{}", out_of(&plain)); + assert!(escaped.is_file()); + let _ = std::fs::remove_dir_all(&d); +} + +/// An output path that is a symbolic link is written through, to its target, as `std::fs::write` +/// always did. Routing the CLI's writes through `file_access` for #833 would otherwise have +/// replaced the link with a regular file, because that write stages the bytes and renames them +/// into place. +#[cfg(unix)] +#[test] +fn an_unconfined_write_follows_a_symbolic_link() { + let d = tmp("symlink-out"); + let _ = std::fs::remove_dir_all(&d); + std::fs::create_dir_all(d.join("real")).unwrap(); + let p = |rel: &str| d.join(rel).to_string_lossy().into_owned(); + std::fs::write(d.join("in.json"), br#"[{"command":"shape.rectangle","params":{"x":0,"y":0,"width":40,"height":20}}]"#).unwrap(); + let made = bin().args(["run", &p("in.json"), "--out", &p("doc.figcraft")]).output().unwrap(); + assert!(made.status.success(), "{}", String::from_utf8_lossy(&made.stderr)); + + let target = d.join("real/target.png"); + std::fs::write(&target, b"placeholder").unwrap(); + let link = d.join("link.png"); + std::os::unix::fs::symlink(&target, &link).unwrap(); + + let out = bin().args(["render", &p("doc.figcraft"), "-o", &link.to_string_lossy()]).output().unwrap(); + assert!(out.status.success(), "{}", String::from_utf8_lossy(&out.stderr)); + assert!(std::fs::symlink_metadata(&link).unwrap().file_type().is_symlink(), "the link is still a link"); + let written = std::fs::read(&target).unwrap(); + assert!(written.starts_with(b"\x89PNG"), "the target holds the render, not the placeholder"); + let _ = std::fs::remove_dir_all(&d); +} 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/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/server_tests.rs b/apps/figcraft-server/src/server_tests.rs index 153bb5ab..f60150da 100644 --- a/apps/figcraft-server/src/server_tests.rs +++ b/apps/figcraft-server/src/server_tests.rs @@ -261,7 +261,9 @@ fn fuzzed_frames_dont_hurt_the_server() { break; } } - let _ = ws.read(); + // read() returns Result, not a lock guard; the fuzz loop ends here on purpose and + // the close frame from the server is not interesting. + let _ = ws.read(); // NOSONAR } // Still serving, the file intact. let mut b = join(&p.edit, "Bo"); diff --git a/apps/figcraft-server/src/store.rs b/apps/figcraft-server/src/store.rs index ea9bc626..a60d482d 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,17 @@ 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 { + s.trim().chars().map(|c| if c.is_control() || is_bidi_control(c) { ' ' } else { c }).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 +312,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 +348,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 +399,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 +449,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 +546,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 +585,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 +810,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 +1035,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 +1158,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 +1180,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 +1254,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 +1333,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..bd1d9c32 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,124 @@ 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(); + } +} + +#[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-web/Cargo.toml b/apps/figcraft-web/Cargo.toml index 2f1a8190..ffd44f3b 100644 --- a/apps/figcraft-web/Cargo.toml +++ b/apps/figcraft-web/Cargo.toml @@ -26,4 +26,4 @@ eframe = { workspace = true, features = ["default_fonts", "wgpu", "accesskit"] } wasm-bindgen = "0.2" js-sys = "0.3" wasm-bindgen-futures = "0.4" -web-sys = { version = "0.3", features = ["Window", "Document", "Element", "HtmlCanvasElement", "HtmlElement", "HtmlAnchorElement", "Node", "Blob", "BlobPropertyBag", "Url", "Location", "MediaQueryList", "Navigator", "Gamepad", "GamepadButton", "GamepadMappingType"] } +web-sys = { version = "0.3", features = ["Window", "Document", "Element", "HtmlCanvasElement", "HtmlElement", "HtmlAnchorElement", "Node", "Blob", "BlobPropertyBag", "File", "FileList", "HtmlInputElement", "EventTarget", "Url", "Location", "MediaQueryList", "Navigator", "Gamepad", "GamepadButton", "GamepadMappingType"] } diff --git a/apps/figcraft-web/src/web.rs b/apps/figcraft-web/src/web.rs index 29213621..f08ca90f 100644 --- a/apps/figcraft-web/src/web.rs +++ b/apps/figcraft-web/src/web.rs @@ -1,7 +1,7 @@ //! The browser shell: web `Services` and the eframe web runner. use figcraft_engine::Session; -use figcraft_ui_egui::{FigApp, Services}; +use figcraft_ui_egui::{ContentsFuture, FigApp, Services}; use wasm_bindgen::JsCast as _; const CANVAS_ID: &str = "figcraft_canvas"; @@ -75,9 +75,10 @@ impl eframe::App for WebShell { } } -/// Browser services. Open dialogs return nothing for now (the trait's defaults); files are saved -/// as downloads. Copies go to the system clipboard through egui's output; pastes arrive as paste -/// events with their text (a page can't read the clipboard when it likes). +/// Browser services. Files are opened and imported through the browser's file picker, which hands +/// over their contents (pickers that need a path return nothing, the trait's defaults); files are +/// saved as downloads. Copies go to the system clipboard through egui's output; pastes arrive as +/// paste events with their text (a page can't read the clipboard when it likes). struct WebServices { /// `prefers-reduced-motion: reduce` (it follows the setting while the page is open). reduce_motion: Option, @@ -85,6 +86,16 @@ struct WebServices { } impl Services for WebServices { + /// An `` taking `exts`, clicked; its file is read once picked. + fn pick_open_contents(&mut self, _title: &str, exts: &[&str]) -> Option { + let accept = exts.iter().map(|e| format!(".{e}")).collect::>().join(","); + Some(Box::pin(async move { + pick_file(&accept).await.unwrap_or_else(|e| { + log::error!("couldn't open a file: {e:?}"); + None + }) + })) + } fn downloads(&self) -> bool { true } @@ -136,6 +147,90 @@ impl Services for WebServices { } } +/// The file picked in the browser's file picker (`accept`: the extensions it offers), with its +/// name; `None` when the picker is cancelled, `Some(Err)` with what to tell the user when the file +/// can't be read. +async fn pick_file(accept: &str) -> Result), String>>, wasm_bindgen::JsValue> { + let window = web_sys::window().ok_or("no window")?; + let document = window.document().ok_or("no document")?; + let body = document.body().ok_or("no body")?; + let input: web_sys::HtmlInputElement = document.create_element("input")?.dyn_into()?; + input.set_type("file"); + input.set_accept(accept); + input.set_hidden(true); + // Some browsers (older Safari) open the picker only for an input in the page. + body.append_child(&input)?; + let (closed, picker) = PickerClosed::new(&window, input.clone()); + input.click(); + let result = wasm_bindgen_futures::JsFuture::from(closed).await; + let file = input.files().and_then(|files| files.get(0)); + drop(picker); + result?; + let Some(file) = file else { return Ok(None) }; + let name = file.name(); + if let Err(why) = figcraft_ui_egui::browser_can_read(&name, file.size()) { + return Ok(Some(Err(why))); + } + Ok(Some(match wasm_bindgen_futures::JsFuture::from(file.array_buffer()).await { + Ok(buffer) => Ok((name, js_sys::Uint8Array::new(&buffer).to_vec())), + Err(e) => { + log::error!("couldn't read {name}: {e:?}"); + Err(figcraft_ui_egui::unreadable(&name)) + } + })) +} + +/// How long after the page gets focus back the file picker counts as closed (milliseconds): a +/// picked file's `change` can come just after the focus, and must win. A `change` later than this +/// (a slow cloud-backed file in a browser that never sends `cancel`) counts as a cancel. +const PICKER_FOCUS_SETTLE_MS: i32 = 1_000; + +/// Settles a file picker's promise once: on `change` (a file was picked), on `cancel` (closed +/// without one) or shortly after the page gets focus back, for browsers that never send `cancel` +/// (Safari before 16.4), where the pick would otherwise stay pending and block every later one. +/// Dropping it (also when the pick is abandoned) removes the input, the focus listener and its timer. +struct PickerClosed { + window: web_sys::Window, + input: web_sys::HtmlInputElement, + _settle: wasm_bindgen::closure::Closure, + on_focus: wasm_bindgen::closure::Closure, + timer: std::rc::Rc>>, +} + +impl PickerClosed { + fn new(window: &web_sys::Window, input: web_sys::HtmlInputElement) -> (js_sys::Promise, Self) { + let resolve = std::rc::Rc::new(std::cell::RefCell::new(None::)); + let slot = resolve.clone(); + let closed = js_sys::Promise::new(&mut |res, _rej| *slot.borrow_mut() = Some(res)); + let settle = wasm_bindgen::closure::Closure::::new(move || { + if let Some(res) = resolve.borrow_mut().take() { + let _ = res.call0(&wasm_bindgen::JsValue::NULL); + } + }); + let _ = input.add_event_listener_with_callback("change", settle.as_ref().unchecked_ref()); + let _ = input.add_event_listener_with_callback("cancel", settle.as_ref().unchecked_ref()); + let timer = std::rc::Rc::new(std::cell::Cell::new(None)); + let (w, t, f) = (window.clone(), timer.clone(), settle.as_ref().unchecked_ref::().clone()); + let on_focus = wasm_bindgen::closure::Closure::::new(move || { + if t.get().is_none() { + t.set(w.set_timeout_with_callback_and_timeout_and_arguments_0(&f, PICKER_FOCUS_SETTLE_MS).ok()); + } + }); + let _ = window.add_event_listener_with_callback("focus", on_focus.as_ref().unchecked_ref()); + (closed, Self { window: window.clone(), input, _settle: settle, on_focus, timer }) + } +} + +impl Drop for PickerClosed { + fn drop(&mut self) { + if let Some(t) = self.timer.take() { + self.window.clear_timeout_with_handle(t); + } + let _ = self.window.remove_event_listener_with_callback("focus", self.on_focus.as_ref().unchecked_ref()); + self.input.remove(); + } +} + fn download(name: &str, bytes: &[u8]) -> Result<(), wasm_bindgen::JsValue> { let window = web_sys::window().ok_or("no window")?; let document = window.document().ok_or("no document")?; diff --git a/apps/figcraft/Cargo.toml b/apps/figcraft/Cargo.toml index fac4842b..6196d08d 100644 --- a/apps/figcraft/Cargo.toml +++ b/apps/figcraft/Cargo.toml @@ -16,6 +16,10 @@ figcraft-ui-egui = { workspace = true } figcraft-collab-net = { workspace = true } figcraft-format = { workspace = true } figcraft-text = { workspace = true } +# Shared decision of who draws the window frame on Linux (compositor vs. app), replacing the +# bespoke Wayland xdg-decoration probe. See `[workspace.dependencies]`: pinned to the unmerged +# craft-libs#217 branch and must be repointed to a merged `rev` before this PR is mergeable. +craft-window = { workspace = true } egui = { workspace = true } eframe = { workspace = true, features = ["default_fonts", "wgpu", "accesskit", "wayland", "x11"] } serde_json = { workspace = true } @@ -45,7 +49,6 @@ wat = { workspace = true } winresource = "0.1" [target.'cfg(target_os = "linux")'.dependencies] -# Use vector window controls without a title-font renderer or embedded Cantarell. +# Use vector window controls without a title-font renderer or embedded Cantarell. The compositor +# probe that used to need raw-window-handle and wayland-client here now lives in craft-window. winit = { version = "0.30", default-features = false, features = ["wayland-csd-adwaita-notitle"] } -raw-window-handle = "0.6" -wayland-client = "0.31" diff --git a/apps/figcraft/src/args.rs b/apps/figcraft/src/args.rs index 3b4136d9..436df61f 100644 --- a/apps/figcraft/src/args.rs +++ b/apps/figcraft/src/args.rs @@ -2,12 +2,14 @@ //! [--automation-write-root

]… [files…] [figcraft://join?…]`. An invite link //! (what the system passes when a `figcraft://` link is opened) joins that session once the window //! is up; everything else that isn't a flag is a file to open, as a path or a `file://` URL (the -//! Linux desktop entry passes URLs, `%U`, so that it can also hand over invite links). +//! Linux desktop entry passes URLs, `%U`, so that it can also hand over invite links). Any other +//! `--flag` is unknown and stops the app at startup (#836): a mistyped root flag must never leave +//! the app running unconfined. After a bare `--`, every argument is a file. /// What the app was started with. #[derive(Debug, Default, PartialEq, Eq)] pub struct Args { - /// `--control `: the JSON-lines control server. + /// `--control ` (or `--control=`): the JSON-lines control server. pub control: Option, pub files: Vec, /// The last invite link given. @@ -17,6 +19,8 @@ pub struct Args { /// which is refused at startup. pub read_roots: Vec, pub write_roots: Vec, + /// `--flags` the app doesn't know, as given. + pub unknown: Vec, } /// An invite link rather than a file (`figcraft:` links, in any case). @@ -55,6 +59,10 @@ pub fn parse(args: &[String]) -> Args { let mut out = Args::default(); let mut it = args.iter(); while let Some(a) = it.next() { + if a == "--" { + out.files.extend(it.by_ref().cloned()); + break; + } let (flag, inline) = a.split_once('=').map_or((a.as_str(), None), |(f, v)| (f, Some(v.to_string()))); let roots = match flag { "--automation-read-root" => Some(&mut out.read_roots), @@ -63,13 +71,15 @@ pub fn parse(args: &[String]) -> Args { }; if let Some(roots) = roots { roots.push(inline.or_else(|| it.next().cloned()).unwrap_or_default()); - } else if a == "--control" { - out.control = it.next().and_then(|p| p.parse().ok()); + } else if flag == "--control" { + out.control = inline.or_else(|| it.next().cloned()).and_then(|p| p.parse().ok()); } else if is_invite(a) { out.join = Some(a.clone()); } else if let Some(path) = file_url_path(a) { out.files.push(path); - } else if !a.starts_with("--") { + } else if a.starts_with("--") { + out.unknown.push(a.clone()); + } else { out.files.push(a.clone()); } } diff --git a/apps/figcraft/src/args_tests.rs b/apps/figcraft/src/args_tests.rs index 2ae29b3c..b274beda 100644 --- a/apps/figcraft/src/args_tests.rs +++ b/apps/figcraft/src/args_tests.rs @@ -9,13 +9,21 @@ fn args(a: &[&str]) -> Vec { #[test] fn files_and_the_control_port() { assert_eq!(parse(&args(&[])), Args::default()); - let a = parse(&args(&["--control", "4242", "a.figcraft", "--verbose", "b.svg"])); + let a = parse(&args(&["--control", "4242", "a.figcraft", "b.svg"])); assert_eq!(a, Args { control: Some(4242), files: args(&["a.figcraft", "b.svg"]), ..Args::default() }); // A missing or bad port is ignored, not taken as a file. assert_eq!(parse(&args(&["--control"])), Args::default()); assert_eq!(parse(&args(&["--control", "nope"])).control, None); } +#[test] +fn the_control_port_accepts_the_equals_form() { + let a = parse(&args(&["--control=7980", "a.figcraft"])); + assert_eq!(a, Args { control: Some(7980), files: args(&["a.figcraft"]), ..Args::default() }); + // A bad port is ignored, not taken as a file. + assert_eq!(parse(&args(&["--control=nope"])), Args::default()); +} + #[test] fn automation_roots_repeat_and_are_never_files() { let a = parse(&args(&["--automation-read-root", "in", "--automation-write-root=out", "--automation-read-root=more", "a.figcraft"])); @@ -26,6 +34,19 @@ fn automation_roots_repeat_and_are_never_files() { assert_eq!(parse(&args(&["--automation-write-root"])).write_roots, args(&[""])); } +#[test] +fn unknown_flags_are_refused_never_ignored() { + // #836: a mistyped root flag (or one an older release lacks) must stop the app, not start it + // unconfined. Both forms, with and without `=`. + let a = parse(&args(&["--automation-write-roots", "out", "--automation-write-dir=out", "--verbose", "a.figcraft"])); + assert_eq!(a.unknown, args(&["--automation-write-roots", "--automation-write-dir=out", "--verbose"])); + assert!(a.write_roots.is_empty()); + // Single-dash arguments are left as they were (files); after `--` everything is a file. + let a = parse(&args(&["--control", "1", "--", "--odd.figcraft", "--automation-write-root=x"])); + assert_eq!(a.files, args(&["--odd.figcraft", "--automation-write-root=x"])); + assert!(a.unknown.is_empty() && a.write_roots.is_empty()); +} + #[test] fn invite_links_join_instead_of_opening() { let link = "figcraft://join?host=192.168.1.20:47900&file=abc&token=AbCdEfGhIjKlMnOpQrStUv"; 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/apps/figcraft/src/linux_window.rs b/apps/figcraft/src/linux_window.rs deleted file mode 100644 index f47b61e0..00000000 --- a/apps/figcraft/src/linux_window.rs +++ /dev/null @@ -1,112 +0,0 @@ -//! Request winit's frame only on Wayland compositors without xdg-decoration. -//! Desktop/session names cannot identify the backend of an individual window. - -use std::os::unix::net::UnixStream; -use std::path::PathBuf; -use std::sync::mpsc; -use std::time::Duration; - -use raw_window_handle::{HasDisplayHandle, RawDisplayHandle}; -use wayland_client::globals::{GlobalListContents, registry_queue_init}; -use wayland_client::protocol::wl_registry; -use wayland_client::{Connection, Dispatch, QueueHandle}; - -/// Capture the socket path before winit consumes an inherited WAYLAND_SOCKET. -/// Never consume, duplicate or query that inherited connection: there may be no -/// independently addressable display, or WAYLAND_DISPLAY may name another server. -pub fn display_socket() -> Option { - if std::env::var_os("WAYLAND_SOCKET").is_some_and(|s| !s.is_empty()) { - return None; - } - let display = std::env::var_os("WAYLAND_DISPLAY").filter(|s| !s.is_empty())?; - let path = PathBuf::from(display); - if path.is_absolute() { - Some(path) - } else { - std::env::var_os("XDG_RUNTIME_DIR").filter(|s| !s.is_empty()).map(|dir| PathBuf::from(dir).join(path)) - } -} - -pub fn needs_native_frame(cc: &eframe::CreationContext<'_>, socket: Option) -> bool { - let wayland = cc.display_handle().is_ok_and(|h| matches!(h.as_raw(), RawDisplayHandle::Wayland(_))); - if !wayland { - log::debug!("window controls: non-Wayland window, retaining custom controls"); - return false; - } - let server_decorations = socket.and_then(probe); - log::debug!("window controls: Wayland xdg-decoration available: {server_decorations:?}"); - native_frame(wayland, server_decorations) -} - -fn native_frame(wayland: bool, server_decorations: Option) -> bool { - wayland && server_decorations == Some(false) -} - -fn probe(socket: PathBuf) -> Option { - // The compositor must not be able to block startup on a registry roundtrip. - // On failure/timeout retain FigCraft's own working title bar and buttons. - let (send, receive) = mpsc::sync_channel(1); - std::thread::Builder::new() - .name("wayland-decorations".into()) - .spawn(move || { - let _ = send.send(registry_has_decorations(socket)); - }) - .ok()?; - receive.recv_timeout(Duration::from_millis(500)).ok().flatten() -} - -fn registry_has_decorations(socket: PathBuf) -> Option { - // A fresh connection leaves winit's event queue and inherited descriptors alone. - let stream = UnixStream::connect(socket).ok()?; - let connection = Connection::from_socket(stream).ok()?; - let (globals, _queue) = registry_queue_init::(&connection).ok()?; - Some(globals.contents().with_list(|list| list.iter().any(|g| g.interface == "zxdg_decoration_manager_v1"))) -} - -struct Registry; - -impl Dispatch for Registry { - fn event( - _state: &mut Self, - _registry: &wl_registry::WlRegistry, - _event: wl_registry::Event, - _data: &GlobalListContents, - _connection: &Connection, - _queue: &QueueHandle, - ) { - } -} - -#[cfg(test)] -mod tests { - #[test] - fn only_wayland_without_server_decorations_uses_the_frame() { - for (wayland, server_decorations, expected) in [ - (false, None, false), - (false, Some(false), false), - (false, Some(true), false), - (true, Some(true), false), - (true, Some(false), true), - (true, None, false), - ] { - assert_eq!(super::native_frame(wayland, server_decorations), expected); - } - } - - #[test] - fn an_unavailable_display_keeps_the_custom_controls() { - assert_eq!(super::probe("/nonexistent/figcraft-wayland-test".into()), None); - } - - #[test] - fn an_unresponsive_display_cannot_block_window_startup() -> std::io::Result<()> { - let socket = std::env::temp_dir().join(format!("figcraft-decoration-timeout-{}", std::process::id())); - let listener = std::os::unix::net::UnixListener::bind(&socket)?; - let start = std::time::Instant::now(); - assert_eq!(super::probe(socket.clone()), None); - assert!(start.elapsed() < std::time::Duration::from_secs(2)); - drop(listener); - let _ = std::fs::remove_file(socket); - Ok(()) - } -} diff --git a/apps/figcraft/src/main.rs b/apps/figcraft/src/main.rs index ddb70204..0b71149a 100644 --- a/apps/figcraft/src/main.rs +++ b/apps/figcraft/src/main.rs @@ -10,7 +10,8 @@ //! `--automation-read-root ` / `--automation-write-root ` (each repeatable) confine the //! files the window and the control channel read and write (`figcraft_engine::file_access`); the //! app's own folders (settings, recovery copies, installed plugins) and opening the files given on -//! the command line are not. A root that isn't an existing folder stops the app at startup. +//! the command line are not. A root that isn't an existing folder stops the app at startup, and so +//! does any flag the app doesn't know (a mistyped root flag must not start the app unconfined). #![deny(clippy::unwrap_used, clippy::expect_used, clippy::panic, clippy::unimplemented, clippy::todo, clippy::unreachable)] #![cfg_attr(all(target_os = "windows", not(debug_assertions)), windows_subsystem = "windows")] @@ -20,8 +21,6 @@ mod args; mod control_server; mod gamepad; mod graphics; -#[cfg(target_os = "linux")] -mod linux_window; mod recovery; mod services; mod startup; @@ -129,6 +128,12 @@ fn main() -> eframe::Result { return Ok(()); } let args = args::parse(&args); + if let Some(flag) = args.unknown.first() { + eprintln!( + "FigCraft: unknown option {flag}\nUsage: figcraft [--control PORT] [--automation-read-root DIR]... [--automation-write-root DIR]... [FILE | figcraft://join?...]..." + ); + std::process::exit(2); + } let roots = match ( args.read_roots.is_empty() && args.write_roots.is_empty(), figcraft_engine::file_access::Roots::new(&args.read_roots, &args.write_roots), @@ -186,10 +191,8 @@ fn main() -> eframe::Result { // default, never a build from source unless asked). session.live.links = figcraft_collab_net::links::system(); session.invite_links_startup(); - // A first run (no settings kept yet, nothing to open) starts on the home screen, with its - // tips and templates; later runs start in a new file. let first_run = svc.prefs.is_none() && args.join.is_none(); - if session.docs.is_empty() && !first_run { + if session.docs.is_empty() && opens_new_file(first_run, session.prefs.start_on_home, port.is_some()) { let _ = session.execute("file.new", &json!({})); } // The interface language from the first frame on, and for the message when the window can't @@ -217,8 +220,13 @@ fn main() -> eframe::Result { .with_inner_size([1440.0, 900.0]) .with_min_inner_size([800.0, 500.0]) .with_drag_and_drop(true); - // Start frameless on Linux; the app creator checks the actual display backend - // before enabling a client-side frame. Other windows inherit that decision. + // Who draws the window frame is decided once, before the viewport is built, by the shared + // craft-window crate (the rest of the Craft suite uses it too). On macOS the system frame is + // kept with FigCraft's inset traffic lights; on Windows it is the system frame; on Linux the + // decision follows the desktop: a Wayland compositor with server-side decorations (KDE, Sway, + // Hyprland, COSMIC) or an X11 window manager keeps the native frame, while GNOME's Mutter — + // which draws no server-side frame — leaves it to the app's own custom title bar. This replaces + // the old bespoke xdg-decoration probe that always drew FigCraft's custom title bar on KDE. fig.ui.title_bar = desktop_title_bar(); viewport = window_chrome(viewport, fig.ui.title_bar); if let Some(icon) = icon { @@ -227,8 +235,6 @@ fn main() -> eframe::Result { let mut options = eframe::NativeOptions { viewport, renderer: eframe::Renderer::Wgpu, ..Default::default() }; graphics::configure(&mut options.wgpu_options); figcraft_ui_egui::windows::install(fig); - #[cfg(target_os = "linux")] - let linux_display = linux_window::display_socket(); // Registered before the event loop starts, so it catches the Finder event that launched the app // as well as later ones. Lives until the event loop returns; the app creator only borrows it. #[cfg(target_os = "macos")] @@ -239,11 +245,6 @@ fn main() -> eframe::Result { "FigCraft", options, Box::new(move |_cc| { - #[cfg(target_os = "linux")] - if linux_window::needs_native_frame(_cc, linux_display) { - let _ = with_app(|fig| fig.ui.title_bar = figcraft_ui_egui::TitleBar::Native); - _cc.egui_ctx.send_viewport_cmd(egui::ViewportCommand::Decorations(true)); - } Ok(Box::new(App { queue, ctx_slot, @@ -261,9 +262,43 @@ fn main() -> eframe::Result { result } +/// With nothing to open, whether the window starts in a new file rather than on the home screen. +/// A first run (no settings kept yet) shows the home screen's tips and templates, and later runs +/// start in a new file unless the preference asks for Home, which `--control` overrides: its +/// callers expect a document to act on. +fn opens_new_file(first_run: bool, start_on_home: bool, control: bool) -> bool { + !first_run && (!start_on_home || control) +} + +/// The title bar FigCraft starts with, decided by the shared craft-window crate so every Craft app +/// agrees on who draws the frame per desktop. The override variable is `FIGCRAFT_TITLEBAR` +/// (`app` / `system`). macOS keeps its inset traffic lights unchanged (craft-window returns the +/// desktop frame there); Windows keeps the system frame; on Linux the compositor/window manager +/// decides (KDE/Sway/Hyprland/COSMIC and X11 keep the native frame, GNOME gets the custom one). fn desktop_title_bar() -> figcraft_ui_egui::TitleBar { + let choice = craft_window::Choice::probe(craft_window::Prefix::new("FIGCRAFT")); + title_bar_for(choice.into_frame()) +} + +/// Map craft-window's [`craft_window::Frame`] onto FigCraft's own [`figcraft_ui_egui::TitleBar`]. +/// +/// `Frame::App` means the app draws its own frame — FigCraft's custom title bar. `Frame::Desktop` +/// means the desktop draws it, but FigCraft keeps each platform's existing look there: +/// - macOS: the inset title bar with its traffic lights (`TitleBar::Inset`), unchanged. +/// - Windows: FigCraft's own custom title bar (`TitleBar::Custom`), unchanged — Windows never used +/// the plain native frame, and craft-window returning `Desktop` on Windows must not change that. +/// - Linux and the BSDs: the compositor's native frame (`TitleBar::Native`). This is the only +/// behaviour change: KDE and the other server-side-decoration compositors now keep their frame. +fn title_bar_for(frame: craft_window::Frame) -> figcraft_ui_egui::TitleBar { use figcraft_ui_egui::TitleBar; - if cfg!(target_os = "macos") { TitleBar::Inset } else { TitleBar::Custom } + match frame { + craft_window::Frame::Desktop if cfg!(target_os = "macos") => TitleBar::Inset, + // Windows kept FigCraft's own title bar before this change; keep it. + craft_window::Frame::Desktop if cfg!(target_os = "windows") => TitleBar::Custom, + craft_window::Frame::Desktop => TitleBar::Native, + // The app draws the frame (GNOME's Wayland session, or a `FIGCRAFT_TITLEBAR=app` override). + craft_window::Frame::App(_) => TitleBar::Custom, + } } fn window_chrome(viewport: egui::ViewportBuilder, title_bar: figcraft_ui_egui::TitleBar) -> egui::ViewportBuilder { @@ -275,13 +310,66 @@ fn window_chrome(viewport: egui::ViewportBuilder, title_bar: figcraft_ui_egui::T } } -#[cfg(all(test, target_os = "linux"))] -mod linux_window_tests { +#[cfg(test)] +mod title_bar_tests { + use figcraft_ui_egui::TitleBar; + + /// craft-window's app frame (GNOME's Wayland session, or a `FIGCRAFT_TITLEBAR=app` override) + /// becomes FigCraft's custom title bar on every platform, and `window_chrome` then drops the + /// decorations so FigCraft draws its own. #[test] - fn linux_starts_with_the_existing_frameless_title_bar() { - let bar = super::desktop_title_bar(); - assert_eq!(bar, figcraft_ui_egui::TitleBar::Custom); + fn the_app_frame_maps_to_the_custom_title_bar() { + let layout = craft_window::Layout::default(); + let bar = super::title_bar_for(craft_window::Frame::App(layout)); + assert_eq!(bar, TitleBar::Custom); let viewport = super::window_chrome(egui::ViewportBuilder::default(), bar); assert_eq!(viewport.decorations, Some(false)); } + + /// craft-window's desktop frame keeps each platform's existing look: macOS its inset traffic + /// lights, Windows FigCraft's own custom title bar (unchanged), and Linux/BSD the compositor's + /// native frame. The Linux case is the only behaviour change: KDE and the other + /// server-side-decoration compositors now keep their native frame. + #[test] + fn the_desktop_frame_is_mapped_per_platform() { + let bar = super::title_bar_for(craft_window::Frame::Desktop); + if cfg!(target_os = "macos") { + assert_eq!(bar, TitleBar::Inset); + } else if cfg!(target_os = "windows") { + // Windows kept its own title bar before and must keep it now. + assert_eq!(bar, TitleBar::Custom); + let viewport = super::window_chrome(egui::ViewportBuilder::default(), bar); + assert_eq!(viewport.decorations, Some(false)); + } else { + assert_eq!(bar, TitleBar::Native); + let viewport = super::window_chrome(egui::ViewportBuilder::default(), bar); + assert_eq!(viewport.decorations, Some(true)); + } + } + + /// macOS is untouched by this change: its inset title bar with the system traffic lights stays, + /// and `window_chrome` still asks for the full-size content view rather than toggling the + /// window decorations. + #[test] + #[cfg(target_os = "macos")] + fn macos_keeps_its_inset_title_bar() { + let bar = super::desktop_title_bar(); + assert_eq!(bar, TitleBar::Inset); + let viewport = super::window_chrome(egui::ViewportBuilder::default(), bar); + assert_eq!(viewport.fullsize_content_view, Some(true)); + assert_eq!(viewport.title_shown, Some(false)); + } +} + +#[cfg(test)] +mod start_tests { + use super::opens_new_file; + + #[test] + fn the_home_preference_starts_on_home_except_for_control_callers() { + assert!(opens_new_file(false, false, false), "a new file by default"); + assert!(!opens_new_file(false, true, false), "Home when the preference asks"); + assert!(opens_new_file(false, true, true), "--control still starts with a document"); + assert!(!opens_new_file(true, false, false), "a first run shows the home screen"); + } } diff --git a/apps/figcraft/src/recovery.rs b/apps/figcraft/src/recovery.rs index 62243bc7..06b0cbeb 100644 --- a/apps/figcraft/src/recovery.rs +++ b/apps/figcraft/src/recovery.rs @@ -120,8 +120,7 @@ impl Recovery { (0..10).find_map(|n| { let dir = root.join(if n == 0 { pid.to_string() } else { format!("{pid}-{n}") }); std::fs::create_dir_all(&dir).ok()?; - let lock = File::options().write(true).create(true).truncate(false).open(dir.join(LOCK)).ok()?; - lock.try_lock().ok()?; + let lock = lock_folder(&dir).ok()?; Some(Self { dir, _lock: lock, written: HashMap::new(), next: 0.0, waiting: Vec::new() }) }) } @@ -293,6 +292,17 @@ fn remove_kept(w: Waiting) { /// The extension of the note beside the copy of a document that is one of your online files. const ONLINE: &str = "online"; +/// How long the startup waits, once, for folder locks that may free themselves (#334). +#[cfg(unix)] +const LOCK_WAIT: std::time::Duration = std::time::Duration::from_millis(100); + +/// Lock the folder `dir` (`WouldBlock` while another process holds it). +fn lock_folder(dir: &Path) -> Result { + let lock = File::options().write(true).create(true).truncate(false).open(dir.join(LOCK)).map_err(std::fs::TryLockError::Error)?; + lock.try_lock()?; + Ok(lock) +} + /// The copy at `file` is of one of your online files: join that file again as its owner, keeping /// the copy to compare with what arrives. The session, or `None` when the copy isn't one (or its /// file isn't recorded any more, or is open already): then it opens as any other copy. @@ -362,6 +372,7 @@ pub fn recover_all(root: &Path, s: &mut Session) -> Recovered { } } }; + let (mut free, mut held) = (Vec::new(), Vec::new()); for e in entries.flatten() { let path = e.path(); if !path.is_dir() { @@ -369,10 +380,22 @@ pub fn recover_all(root: &Path, s: &mut Session) -> Recovered { continue; } // Still running (another window of the app), or not ours to take. - let Ok(lock) = File::options().write(true).create(true).truncate(false).open(path.join(LOCK)) else { continue }; - if lock.try_lock().is_err() { - continue; + match lock_folder(&path) { + Ok(lock) => free.push((path, lock)), + Err(std::fs::TryLockError::WouldBlock) => held.push(path), + Err(_) => {} } + } + // A child between its `fork` and its `exec` shares the run's open descriptors, and a `flock` + // lasts while any of them is open, so an ended run's folder can look held for a moment. Wait + // once, however many folders are held, and try those again (#334). Windows doesn't pass the + // lock to child processes. + #[cfg(unix)] + if !held.is_empty() { + std::thread::sleep(LOCK_WAIT); + free.extend(held.into_iter().filter_map(|path| lock_folder(&path).ok().map(|lock| (path, lock)))); + } + for (path, lock) in free { let lock = Arc::new(lock); let files: Vec = std::fs::read_dir(&path).into_iter().flatten().flatten().map(|f| f.path()).collect(); for f in &files { diff --git a/apps/figcraft/src/recovery_tests.rs b/apps/figcraft/src/recovery_tests.rs index 8f384b87..0322e237 100644 --- a/apps/figcraft/src/recovery_tests.rs +++ b/apps/figcraft/src/recovery_tests.rs @@ -203,6 +203,29 @@ fn a_run_whose_folder_name_is_still_held_takes_another() { let _ = std::fs::remove_dir_all(&root); } +/// #334: a lock that frees itself is not a run that is still going. A child between its `fork` and +/// its `exec` inherits the run's open descriptors and a `flock` lasts while any descriptor for the +/// file is open, so a crashed run's copies must still come back once that child has exec'd. +#[cfg(unix)] +#[test] +fn a_lock_freed_a_moment_later_is_not_a_run_that_is_still_going() { + let root = root("fork-window"); + crashed(&edited(), &root); + // Hold the crashed run's folder lock on a second descriptor, the way a child forked before its + // exec holds it, and free it shortly after. + let lock = std::fs::File::options().write(true).create(true).truncate(false).open(root.join("crashed").join("lock")).unwrap(); + lock.try_lock().unwrap(); + let freed = std::thread::spawn(move || { + std::thread::sleep(std::time::Duration::from_millis(10)); + drop(lock); + }); + // The next start waits for that lock rather than reading the run as still going. + let mut next = Session::empty(); + assert_eq!(recover(&root, &mut next), 1, "the copies come back once the lock frees itself"); + freed.join().unwrap(); + let _ = std::fs::remove_dir_all(&root); +} + /// The rectangles in `doc`. fn rectangles(doc: &figcraft_engine::doc::Document) -> usize { doc.nodes().filter(|n| matches!(n.kind, figcraft_engine::doc::NodeKind::Rectangle)).count() diff --git a/contributors/people.toml b/contributors/people.toml index 8b14373e..6a92a2a9 100644 --- a/contributors/people.toml +++ b/contributors/people.toml @@ -24,6 +24,10 @@ display_name = "VanTan" real_name = "Tyler Jennings" display_name = "tylerjenningsw" +[people.SiadeLucas] +real_name = "Siade Lucas" +sync_github_name = true + [people.Silvertree2010] display_name = "Netter" sync_github_name = false @@ -37,6 +41,11 @@ real_name = "Justin John" [people.Losminek] +[people.alrifatsabbir] +real_name = "Al Rifat Sabbir" +display_name = "Al Rifat Sabbir" +sync_github_name = true + [people.RevDonz] [people.Nael-Nathanael] @@ -56,3 +65,14 @@ sync_github_name = true [people.pechnikov2026-star] sync_github_name = true + +[people.MortenHusted] +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/code_tests.rs b/crates/collab-net/src/code_tests.rs index 4880242f..7fe766ec 100644 --- a/crates/collab-net/src/code_tests.rs +++ b/crates/collab-net/src/code_tests.rs @@ -141,7 +141,9 @@ fn old_server() -> (Endpoint, std::thread::JoinHandle<()>) { let t = std::thread::spawn(move || { for stream in listener.incoming().take(2).flatten() { let Ok(mut ws) = tungstenite::accept(stream) else { continue }; - let _ = ws.read(); + // read() returns Result, not a lock guard; this test server deliberately + // ignores whatever the client sent first. + let _ = ws.read(); // NOSONAR let refused = ServerMsg::Refused { reason: "that message couldn't be read".into() }; let _ = ws.send(Message::Binary(Bytes::from(message::encode(&refused).unwrap()))); let _ = ws.close(None); diff --git a/crates/collab-net/src/driver.rs b/crates/collab-net/src/driver.rs index b8f6c154..4ebbec15 100644 --- a/crates/collab-net/src/driver.rs +++ b/crates/collab-net/src/driver.rs @@ -16,7 +16,7 @@ use figcraft_engine::live::{Event, Status}; use web_time::Instant; use crate::inbox::Inbox; -use crate::join::{GuestConfig, Next, Outcome, Resuming, Seen, Taken, after, closed, hello, resolved, take, unresolved}; +use crate::join::{GuestConfig, Next, Outcome, Resuming, Seen, Taken, after, closed, hello, resolved, take, too_large, unresolved}; use crate::throttle::{PRESENCE_INTERVAL, Throttle, earliest}; /// A WebSocket the driver opens, writes and closes; what it receives comes back through the @@ -114,6 +114,9 @@ impl Driver { /// A binary message from the host. pub(crate) fn message(&mut self, bytes: &[u8], now: Instant) { let Some(c) = self.conn.as_mut() else { return }; + if bytes.len() > self.cfg.limits.max_server_message { + return self.end(too_large(&self.cfg.limits), now); + } c.last_heard = now; let taken = match message::decode::(bytes, self.cfg.limits.max_server_message) { // The join code's answer: join the file it opens, on a new connection. diff --git a/crates/collab-net/src/driver_tests.rs b/crates/collab-net/src/driver_tests.rs index ca6563c5..5e395b98 100644 --- a/crates/collab-net/src/driver_tests.rs +++ b/crates/collab-net/src/driver_tests.rs @@ -10,7 +10,7 @@ use std::time::{Duration, Instant}; use figcraft_collab::message::{self, ClientMsg, Presence, ServerMsg}; use figcraft_collab::{Client, Limits, Outgoing, Server}; use figcraft_doc::{Document, NodeId}; -use figcraft_engine::live::{Event, Status}; +use figcraft_engine::live::{Event, Status, TOO_LARGE_TO_JOIN}; use tungstenite::protocol::WebSocket; use tungstenite::{Bytes, Message}; @@ -262,6 +262,15 @@ fn refusals_and_the_end_of_the_session_are_final() { assert!(d.done()); assert!(matches!(statuses(&inbox.drain()).last(), Some(Status::Failed { reason }) if reason.starts_with("The host refused"))); } + // A message over the size limit: the file is too large for this app, not refused by the host. + let inbox = Inbox::new(Arc::new(|| {})); + let mut cfg = GuestConfig::new(invite(), "Ada".into()); + cfg.limits.max_server_message = 2 << 20; + let mut d = Driver::start(Script::default(), cfg, inbox.clone(), t0); + d.opened(t0); + d.message(&vec![0; (2 << 20) + 1], t0); + assert!(d.done()); + assert!(matches!(statuses(&inbox.drain()).last(), Some(Status::Failed { reason }) if *reason == TOO_LARGE_TO_JOIN.replace("{mb}", "2"))); } #[test] diff --git a/crates/collab-net/src/guest.rs b/crates/collab-net/src/guest.rs index eb899b8d..2e1d5046 100644 --- a/crates/collab-net/src/guest.rs +++ b/crates/collab-net/src/guest.rs @@ -26,7 +26,7 @@ use tungstenite::{Bytes, Message}; use crate::inbox::Inbox; use crate::invite::{Endpoint, Invite}; pub use crate::join::GuestConfig; -use crate::join::{Next, Outcome, Resuming, Seen, Taken, after, closed, hello, resolved, take, unresolved}; +use crate::join::{Next, Outcome, Resuming, Seen, Taken, after, closed, hello, resolved, take, too_large, unresolved}; use crate::manage; use crate::net::{Duplex, NetLimits, config, open, shutdown}; use crate::throttle::{PRESENCE_INTERVAL, Throttle, earliest}; @@ -367,7 +367,7 @@ fn read_loop(mut ws: WebSocket, ctx: &ReaderCtx) { Ok(Message::Close(f)) => break f.map_or_else(|| closed(0, ""), |f| closed(f.code.into(), &f.reason)), Ok(Message::Text(_)) => break Outcome::Refused("the host sent a text message".into()), Ok(_) => {} - Err(tungstenite::Error::Capacity(_)) => break Outcome::Refused("the host sent a message over the size limit".into()), + Err(tungstenite::Error::Capacity(_)) => break too_large(&ctx.cfg.limits), Err(e) => break Outcome::Retry(e.to_string()), } }; diff --git a/crates/collab-net/src/hub.rs b/crates/collab-net/src/hub.rs index 5cf3e519..2f87334d 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,10 @@ 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). + Ok(Message::Ping(_) | Message::Pong(_) | Message::Frame(_)) 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 +862,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; } @@ -923,7 +935,7 @@ 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) { + match next_message(&mut ws, limits.max_hello, Some(hello_deadline)) { Ok(Some(( first @ (ClientMsg::Hello(_) | ClientMsg::Manage(_) @@ -946,9 +958,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 }); } @@ -981,7 +997,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 +1090,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 +1213,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 +1222,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 +1417,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 +1448,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 +1465,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 +1565,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 +1617,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 +1773,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/invite.rs b/crates/collab-net/src/invite.rs index 617ad518..e5d86bf2 100644 --- a/crates/collab-net/src/invite.rs +++ b/crates/collab-net/src/invite.rs @@ -220,7 +220,8 @@ fn valid_host(s: &str) -> bool { return true; } let Some((name, port)) = s.rsplit_once(':') else { return false }; - port.parse::().is_ok_and(|p| p != 0) + port.bytes().all(|b| b.is_ascii_digit()) + && port.parse::().is_ok_and(|p| p != 0) && (1..=253).contains(&name.len()) && name.bytes().all(|b| b.is_ascii_alphanumeric() || b == b'-' || b == b'.') && !name.starts_with(['-', '.']) @@ -239,6 +240,9 @@ fn percent_decode(s: &str) -> Option { while let Some(&b) = bytes.get(i) { if b == b'%' { let hex = bytes.get(i + 1..i + 3)?; + if !hex.iter().all(u8::is_ascii_hexdigit) { + return None; + } out.push(u8::from_str_radix(std::str::from_utf8(hex).ok()?, 16).ok()?); i += 3; } else { diff --git a/crates/collab-net/src/invite_tests.rs b/crates/collab-net/src/invite_tests.rs index 02017517..b7618dc2 100644 --- a/crates/collab-net/src/invite_tests.rs +++ b/crates/collab-net/src/invite_tests.rs @@ -123,3 +123,19 @@ proptest! { prop_assert_eq!(Invite::parse(&i.url()), Ok(i)); } } + +#[test] +fn plus_or_sign_inside_a_percent_escape_is_broken() { + assert!(Endpoint::parse("ws://example.org/a/?key=%41").is_ok()); + for bad in ["%+1", "%-1", "%1", "%+a"] { + assert!(Endpoint::parse(&format!("ws://example.org/a/?key={bad}")).is_err(), "{bad}"); + } +} + +#[test] +fn port_must_be_all_digits() { + assert!(Endpoint::parse("ws://localhost:80/").is_ok()); + for bad in ["ws://localhost:+80/", "ws://localhost:-80/", "ws://localhost:/"] { + assert!(Endpoint::parse(bad).is_err(), "accepted {bad}"); + } +} diff --git a/crates/collab-net/src/join.rs b/crates/collab-net/src/join.rs index 81cd2437..cc3be839 100644 --- a/crates/collab-net/src/join.rs +++ b/crates/collab-net/src/join.rs @@ -7,7 +7,7 @@ use std::time::Duration; use figcraft_collab::Client; use figcraft_collab::message::{ClientMsg, Peer, Resume, ServerMsg}; -use figcraft_engine::live::{Event, Role, SessionInfo, Status}; +use figcraft_engine::live::{Event, Role, SessionInfo, Status, TOO_LARGE_TO_JOIN}; use crate::inbox::Inbox; use crate::invite::Invite; @@ -95,6 +95,12 @@ pub(crate) fn unresolved(cfg: &GuestConfig, outcome: Outcome) -> Outcome { }) } +/// The host sent a message over this app's size limit (the file's welcome, usually): final, and +/// not the host's doing. +pub(crate) fn too_large(limits: &NetLimits) -> Outcome { + Outcome::Failed(TOO_LARGE_TO_JOIN.replace("{mb}", &(limits.max_server_message >> 20).to_string())) +} + /// The server's answer to the join code: the invite is whole now. pub(crate) fn resolved(cfg: &mut GuestConfig, file: String, token: String) -> Result<(), Outcome> { if !crate::invite::valid_id(&file, 1, 64) || !crate::invite::valid_id(&token, 16, 128) { diff --git a/crates/collab-net/src/loopback_tests.rs b/crates/collab-net/src/loopback_tests.rs index 32c86bd0..464d8714 100644 --- a/crates/collab-net/src/loopback_tests.rs +++ b/crates/collab-net/src/loopback_tests.rs @@ -10,7 +10,7 @@ use std::time::{Duration, Instant}; use figcraft_collab::message::{self, ClientMsg, Hello, Presence, ServerMsg, Signal}; use figcraft_collab::{Client, Limits}; use figcraft_doc::{Document, NodeId}; -use figcraft_engine::live::{Event, PEOPLE, Status}; +use figcraft_engine::live::{Event, PEOPLE, Status, TOO_LARGE_TO_JOIN}; use tungstenite::protocol::WebSocket; use tungstenite::{Bytes, Message}; @@ -642,3 +642,62 @@ fn the_host_removes_someone_and_they_cant_come_back() { host.stop(); assert_eq!(host.connections(), 0); } + +#[test] +fn a_welcome_over_the_size_limit_says_the_file_is_too_large() { + let mut doc = Document::new("B".repeat(1 << 20)); + doc.repair(); + let probe = Probe::new(); + let cfg = HostConfig { + addr: SocketAddr::from(([127, 0, 0, 1], 0)), + any_port_if_taken: false, + file: FILE.into(), + token: TOKEN.into(), + doc, + name: "Hana".into(), + limits: fast_limits(), + core: Limits::default(), + }; + let mut host = Host::start(cfg, probe.inbox.clone()).unwrap(); + let invite = Invite::new(host.addr().to_string(), FILE, TOKEN); + + let mut guest_cfg = quick(GuestConfig::new(invite, "Ada".into())); + guest_cfg.limits = NetLimits { max_server_message: 1 << 20, ..fast_limits() }; + let mut gp = Probe::new(); + let _g = Guest::start(guest_cfg, gp.inbox.clone()).unwrap(); + let too_large = TOO_LARGE_TO_JOIN.replace("{mb}", "1"); + gp.wait("the file too large to join", |e| e.iter().any(|ev| matches!(ev, Event::Status(Status::Failed { reason }) if *reason == too_large))); + assert!(!welcomed(&gp.events)); + host.stop(); +} + +#[test] +fn pings_do_not_keep_a_connection_without_a_hello_alive() { + 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 = None; + while t0.elapsed() < Duration::from_secs(6) { + if r.0.send(Message::Ping(Bytes::new())).is_err() || r.0.flush().is_err() { + 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; + } + _ => {} + } + } + assert!(closed.is_some_and(|t| t < Duration::from_secs(3)), "pinged pre-hello connection survived: {closed:?}"); + host.stop(); +} diff --git a/crates/collab-net/src/manage.rs b/crates/collab-net/src/manage.rs index 6e995bdb..dc3ec315 100644 --- a/crates/collab-net/src/manage.rs +++ b/crates/collab-net/src/manage.rs @@ -206,10 +206,15 @@ fn connect(server: &Endpoint, limits: &NetLimits, timeout: Duration) -> Result Result<(), String> { @@ -240,3 +245,31 @@ fn reply(ws: &mut Ws, limits: &NetLimits, pick: impl Fn(ServerMsg) -> Option< }; } } + +#[cfg(test)] +mod tests { + use super::*; + + /// 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 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(); + let Ok(mut ws) = tungstenite::accept(s) else { return }; + while ws.send(Message::Ping(Bytes::new())).is_ok() { + std::thread::sleep(Duration::from_millis(10)); + } + }); + let limits = NetLimits { idle_timeout: Duration::from_millis(300), ..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(()); + }); + assert!(rx.recv_timeout(Duration::from_secs(3)).is_ok(), "close() kept draining a pinging server"); + } +} 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/Cargo.toml b/crates/collab/Cargo.toml index 89a79687..ed85201d 100644 --- a/crates/collab/Cargo.toml +++ b/crates/collab/Cargo.toml @@ -13,6 +13,7 @@ figcraft-doc = { workspace = true } serde = { workspace = true } serde_json = { workspace = true } thiserror = { workspace = true } +chrono = { workspace = true } imbl = { workspace = true } base64 = { workspace = true } postcard = { workspace = true } 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 32a49184..57bb9cf2 100644 --- a/crates/collab/src/change.rs +++ b/crates/collab/src/change.rs @@ -87,8 +87,9 @@ macro_rules! node_props { ($($variant:ident => $field:ident),* $(,)?) => { /// A node property: the tree position (`parent`, `position`) or one saved field of /// [`Node`]. List-valued fields (fills, text runs, vector networks…) are one property each, - /// replaced whole (three-way merges line fills, strokes and effects up item by item, see - /// [`merge`](crate::merge)). New properties go at the end (the binary form numbers them). + /// replaced whole (three-way merges line the lists whose items stand on their own, such as + /// fills, up item by item, see [`merge`](crate::merge)). New properties go at the end (the + /// binary form numbers them). #[derive(Clone, Copy, Debug, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize, Deserialize)] #[serde(rename_all = "camelCase")] pub enum Prop { @@ -180,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, @@ -218,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/code.rs b/crates/collab/src/code.rs index c046682a..6eebe9f7 100644 --- a/crates/collab/src/code.rs +++ b/crates/collab/src/code.rs @@ -135,7 +135,7 @@ impl Local { let query = link.strip_prefix("figcraft://join?")?; let get = |key: &str| query.split('&').find_map(|kv| kv.split_once('=').filter(|(k, _)| *k == key).map(|(_, v)| v)); let addr: std::net::SocketAddrV4 = get("host")?.parse().ok()?; - let hex = get("token")?.strip_prefix(TOKEN_PREFIX).filter(|h| h.len() == 11)?; + let hex = get("token")?.strip_prefix(TOKEN_PREFIX).filter(|h| h.len() == 11 && h.bytes().all(|b| b.is_ascii_hexdigit()))?; let local = Local { ip: *addr.ip(), port: addr.port(), secret: u64::from_str_radix(hex, 16).ok()? }; (get("file") == Some(LOCAL_FILE) && local.port != 0).then_some(local) } diff --git a/crates/collab/src/code_tests.rs b/crates/collab/src/code_tests.rs index 2dd902b3..31bd3488 100644 --- a/crates/collab/src/code_tests.rs +++ b/crates/collab/src/code_tests.rs @@ -104,6 +104,7 @@ fn only_local_session_links_have_a_code() { "figcraft://join?host=[::1]:47900&file=local&token=local-0123456789a", "figcraft://join?host=192.168.1.20:0&file=local&token=local-0123456789a", "figcraft://join?host=192.168.1.20:47900&file=local&token=local-0123456789az", + "figcraft://join?host=10.0.0.1:47900&file=local&token=local-+0000000001", "figcraft://join?server=wss://example.org/&file=local&token=local-0123456789a", "ws://192.168.1.20:47900/?file=local&token=local-0123456789a", "", 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/collab/src/merge.rs b/crates/collab/src/merge.rs index bb18e661..5132ce75 100644 --- a/crates/collab/src/merge.rs +++ b/crates/collab/src/merge.rs @@ -4,12 +4,13 @@ //! //! A conflict is the same property (or document-level entry) changed on both sides to different //! values, or a layer deleted on one side while the other side edited it or something inside it -//! (including adding layers to it or moving layers into or out of it). Fills, strokes and effects -//! merge item by item: both sides changing different items of the list (or one adding items at its -//! end) is no conflict, only the same item changed both ways or the list shortened on a side -//! while the other changed it. Nothing that conflicts is -//! merged until it is resolved: each conflict is resolved for one side, and the other side's -//! changes to it are left out. Everything else from both sides is kept. +//! (including adding layers to it or moving layers into or out of it). The layer lists whose items +//! stand on their own (fills, strokes, effects, layout grids, export settings, guides, prototype +//! interactions, accessibility annotations and Dev Mode resources) merge item by item: both sides +//! changing different items of the list (or one adding items at its end) is no conflict, only the +//! same item changed both ways or the list shortened on a side while the other changed it. Nothing +//! that conflicts is merged until it is resolved: each conflict is resolved for one side, and the +//! other side's changes to it are left out. Everything else from both sides is kept. use std::collections::{BTreeMap, BTreeSet}; use std::fmt; @@ -159,8 +160,23 @@ fn by_key(changes: &[Change]) -> BTreeMap> { out } -/// List properties whose items merge one by one. -const PER_ITEM: &[Prop] = &[Prop::Fills, Prop::Strokes, Prop::Effects]; +/// List properties whose items merge one by one: the items stand on their own, so both sides +/// changing different ones (or one side adding at its end) is no conflict. Sets and keyed maps +/// (`bound`, `varModes`, `overrides`, `propRefs`, `pluginData`) are not here: their items line up +/// by key, not by position. +const PER_ITEM: &[Prop] = &[ + Prop::Fills, + Prop::Strokes, + Prop::Effects, + Prop::Grids, + Prop::Exports, + Prop::Guides, + Prop::Interactions, + Prop::Annotations, + // Not `CodeLinks`: there is one per language, and lining them up by position could leave two + // for one language (the second hidden), so they merge whole. + Prop::DevResources, +]; /// Both sides' writes `b` and `m` of list property `key` merged item by item against the base /// (see [`merge_items`]); `None` when `key` isn't such a list or the writes conflict. diff --git a/crates/collab/src/merge_list_tests.rs b/crates/collab/src/merge_list_tests.rs index 436e6b6f..e92f6bbb 100644 --- a/crates/collab/src/merge_list_tests.rs +++ b/crates/collab/src/merge_list_tests.rs @@ -1,8 +1,10 @@ -//! Fills, strokes and effects merge item by item: different items changed on each side merge, -//! the same item changed both ways conflicts. +//! The layer lists whose items stand on their own (fills, strokes, effects, layout grids, export +//! settings, guides, prototype interactions, accessibility annotations and Dev Mode resources) +//! merge item by item: different items changed on each side merge, the same item changed both ways +//! conflicts. use figcraft_color::{Color, Paint}; -use figcraft_doc::{Document, Effect, NodeId, NodeKind}; +use figcraft_doc::{Document, Effect, LayoutGrid, NodeId, NodeKind, proto}; use serde_json::{Value, json}; use crate::merge::{Key, Side, merge_items, plan}; @@ -100,19 +102,113 @@ fn the_same_item_changed_both_ways_conflicts() { } #[test] -fn other_lists_still_conflict_as_a_whole() { - // Docs silent: lists other than fills, strokes and effects (export settings here) still - // conflict whole when both sides change them. +fn the_other_item_lists_merge_one_by_one_too() { + // Layout grids, export settings, guides, accessibility annotations and Dev Mode resources are + // item lists like fills: both sides changing different items merges. let (mut base, _, _, r) = files(); let export = |suffix: &str| figcraft_doc::ExportSetting { suffix: suffix.into(), ..Default::default() }; - base.get_mut(r).unwrap().exports = vec![export("@1"), export("@2")]; + let guide = |vertical: bool, offset: f64| figcraft_doc::Guide { vertical, offset }; + let annot = |label: &str| figcraft_doc::Annotation { label: label.into(), properties: Vec::new(), category: None }; + let grid = |size: f64| LayoutGrid { size, ..Default::default() }; + let res = |url: &str| figcraft_doc::DevResource { url: url.into(), name: String::new() }; + let n = base.get_mut(r).unwrap(); + n.exports = vec![export("@1"), export("@2")]; + n.guides = vec![guide(true, 10.0), guide(false, 20.0)]; + n.annotations = vec![annot("a1"), annot("a2")]; + n.grids = vec![grid(10.0), grid(20.0)]; + n.dev_resources = vec![res("https://a.test"), res("https://b.test")]; let (mut branch, mut main) = (base.clone(), base.clone()); branch.set_client_id(base.highest_id_space() + 1); - branch.get_mut(r).unwrap().exports[0] = export("@b"); - main.get_mut(r).unwrap().exports[1] = export("@m"); + // The branch changes the first item of each list; main the second. + let b = branch.get_mut(r).unwrap(); + (b.exports[0].suffix, b.guides[0].offset, b.annotations[0].label, b.grids[0].size, b.dev_resources[0].url) = + ("@b".into(), 11.0, "b1".into(), 12.0, "https://branch.test".into()); + let m = main.get_mut(r).unwrap(); + (m.exports[1].suffix, m.guides[1].offset, m.annotations[1].label, m.grids[1].size, m.dev_resources[1].url) = + ("@m".into(), 22.0, "m2".into(), 24.0, "https://main.test".into()); + both_ways(&base, &branch, &main, |doc| { + let n = doc.node(r).unwrap(); + let suffixes: Vec<&str> = n.exports.iter().map(|e| e.suffix.as_str()).collect(); + assert_eq!(suffixes, ["@b", "@m"], "export settings"); + let offsets: Vec = n.guides.iter().map(|g| g.offset).collect(); + assert_eq!(offsets, [11.0, 22.0], "guides"); + let labels: Vec<&str> = n.annotations.iter().map(|a| a.label.as_str()).collect(); + assert_eq!(labels, ["b1", "m2"], "annotations"); + let sizes: Vec = n.grids.iter().map(|g| g.size).collect(); + assert_eq!(sizes, [12.0, 24.0], "layout grids"); + let urls: Vec<&str> = n.dev_resources.iter().map(|d| d.url.as_str()).collect(); + assert_eq!(urls, ["https://branch.test", "https://main.test"], "dev resources"); + }); +} + +#[test] +fn the_same_item_of_another_list_changed_both_ways_conflicts() { + let (mut base, _, _, r) = files(); + let link = |language: &str| figcraft_doc::CodeLink { language: language.into(), ..Default::default() }; + let n = base.get_mut(r).unwrap(); + n.exports = vec![ + figcraft_doc::ExportSetting { suffix: "@1".into(), ..Default::default() }, + figcraft_doc::ExportSetting { suffix: "@2".into(), ..Default::default() }, + ]; + n.code_links = vec![link("react"), link("vue")]; + let (mut branch, mut main) = (base.clone(), base.clone()); + branch.set_client_id(base.highest_id_space() + 1); + branch.get_mut(r).unwrap().exports[0].suffix = "@b".into(); + main.get_mut(r).unwrap().exports[0].suffix = "@m".into(); + // Code links are one per language, not an item list: different links changed still conflict. + branch.get_mut(r).unwrap().code_links[0].name = "B".into(); + main.get_mut(r).unwrap().code_links[1].name = "M".into(); let p = plan(&base, &branch, &main).unwrap(); - let keys: Vec = p.conflicts.iter().map(|c| c.key.to_string()).collect(); - assert_eq!(keys, [format!("{r}/exports")]); + let mut keys: Vec = p.conflicts.iter().map(|c| c.key.to_string()).collect(); + keys.sort(); + let mut want = [format!("{r}/codeLinks"), format!("{r}/exports")]; + want.sort(); + assert_eq!(keys, want); +} + +#[test] +fn interactions_merge_item_by_item() { + let ia = |duration_ms: u32| proto::Interaction { + trigger: Default::default(), + actions: vec![proto::Action::Back], + transition: Default::default(), + duration_ms, + easing: Default::default(), + delay_ms: 0, + }; + let durations = |doc: &Document, r: NodeId| doc.node(r).unwrap().interactions.iter().map(|i| i.duration_ms).collect::>(); + let (mut base, _, _, r) = files(); + base.get_mut(r).unwrap().interactions = vec![ia(100), ia(200)]; + let fork = || { + let (mut branch, main) = (base.clone(), base.clone()); + branch.set_client_id(base.highest_id_space() + 1); + (branch, main) + }; + // The branch changes the first interaction and adds one; main changes the second. + let (mut branch, mut main) = fork(); + branch.get_mut(r).unwrap().interactions[0].duration_ms = 150; + branch.get_mut(r).unwrap().interactions.push(ia(300)); + main.get_mut(r).unwrap().interactions[1].duration_ms = 250; + both_ways(&base, &branch, &main, |doc| assert_eq!(durations(doc, r), [150, 250, 300])); + // The branch deletes one while main leaves them alone. + let (mut branch, mut main) = fork(); + branch.get_mut(r).unwrap().interactions.remove(0); + main.get_mut(r).unwrap().fills[0] = red(); + both_ways(&base, &branch, &main, |doc| assert_eq!(durations(doc, r), [200])); + // The same interaction changed both ways, or one deleted while the other side changed the + // list, conflicts. + for delete in [false, true] { + let (mut branch, mut main) = fork(); + if delete { + branch.get_mut(r).unwrap().interactions.remove(0); + } else { + branch.get_mut(r).unwrap().interactions[1].duration_ms = 220; + } + main.get_mut(r).unwrap().interactions[1].duration_ms = 250; + let p = plan(&base, &branch, &main).unwrap(); + let keys: Vec = p.conflicts.iter().map(|c| c.key.to_string()).collect(); + assert_eq!(keys, [format!("{r}/interactions")], "delete: {delete}"); + } } #[test] diff --git a/crates/collab/src/message.rs b/crates/collab/src/message.rs index d035042b..4fda776a 100644 --- a/crates/collab/src/message.rs +++ b/crates/collab/src/message.rs @@ -696,22 +696,38 @@ pub enum ServerMsg { }, } +/// (days since 1970-01-01, seconds-of-day, nanos) → (proleptic Gregorian year, the same wall-clock +/// instant moved into chrono's range by whole 400-year cycles). The calendar repeats every 400 +/// years (146_097 days), so month, day and time of day come from chrono and only the year is +/// carried, which keeps the result exact for any `i64` day count. This is not hand-rolled calendar +/// math: chrono still does the date, the cycle count just shifts the year. +fn utc_parts(days: i64, sod: u32, nanos: u32) -> (i64, chrono::NaiveDateTime) { + use chrono::Datelike; + let (cycles, rest) = (days.div_euclid(146_097), days.rem_euclid(146_097)); + // `rest + 719_163` (1970-01-01 is CE day 719_163) is always a valid CE day, and `sod` < 86_400. + let date = i32::try_from(rest + 719_163).ok().and_then(chrono::NaiveDate::from_num_days_from_ce_opt); + let time = chrono::NaiveTime::from_num_seconds_from_midnight_opt(sod.min(86_399), nanos.min(999_999_999)); + match (date, time) { + (Some(d), Some(t)) => { + let y = i128::from(d.year()) + i128::from(cycles) * 400; + (i64::try_from(y).unwrap_or(if y < 0 { i64::MIN } else { i64::MAX }), d.and_time(t)) + } + _ => (1970, chrono::NaiveDateTime::default()), // unreachable + } +} + /// `YYYY-MM-DD HH:MM UTC` for seconds since 1970 (how servers and apps show the times in these -/// messages). +/// messages). Years past chrono's own range (262143) keep converting: the instant is moved into +/// range by whole 400-year cycles and the real year restored, so every `u64` reads as the date the +/// old hand-rolled conversion produced (e.g. `u64::MAX` → `584554051223-11-09 07:00 UTC`). pub fn utc_date(secs: u64) -> String { - let days = i64::try_from(secs / 86_400).unwrap_or(0); - let (h, m) = ((secs % 86_400) / 3600, (secs % 3600) / 60); - // Days to a civil date (proleptic Gregorian). - let z = days + 719_468; - let era = z.div_euclid(146_097); - let doe = z.rem_euclid(146_097); - let yoe = (doe - doe / 1460 + doe / 36_524 - doe / 146_096) / 365; - let doy = doe - (365 * yoe + yoe / 4 - yoe / 100); - let mp = (5 * doy + 2) / 153; - let d = doy - (153 * mp + 2) / 5 + 1; - let mo = if mp < 10 { mp + 3 } else { mp - 9 }; - let y = yoe + era * 400 + i64::from(mo <= 2); - format!("{y:04}-{mo:02}-{d:02} {h:02}:{m:02} UTC") + // The day count of any `u64` seconds fits in `i64` (`u64::MAX / 86_400` ≈ 2.1e14), and the + // seconds-of-day is < 86_400, so the conversion is always exact (no epoch fallback). Splitting + // with `u64` arithmetic (as the old code did) avoids the `i64` seconds overflow near `u64::MAX`. + let (days, sod) = (i64::try_from(secs / 86_400).unwrap_or(i64::MAX), (secs % 86_400) as u32); + let (y, t) = utc_parts(days, sod, 0); + // `{y:04}` rather than `%Y`, which writes a `+` before years past 9999 (the old code did not). + format!("{y:04}-{}", t.format("%m-%d %H:%M UTC")) } /// The binary form of `msg`: [`WIRE_VERSION`], then postcard. @@ -752,3 +768,25 @@ pub fn decode_json(text: &str, max_bytes: usize) -> Result< } serde_json::from_str(text).map_err(|e| Error::Decode(e.to_string())) } + +#[cfg(test)] +mod tests { + use super::*; + + /// `utc_date` keeps the exact `YYYY-MM-DD HH:MM UTC` output the hand-rolled civil-from-days + /// code produced (leap days and all). + #[test] + fn utc_date_formats_seconds_since_1970() { + assert_eq!(utc_date(0), "1970-01-01 00:00 UTC"); + assert_eq!(utc_date(1_000_000), "1970-01-12 13:46 UTC"); + assert_eq!(utc_date(951_782_400), "2000-02-29 00:00 UTC"); + assert_eq!(utc_date(1_709_164_800), "2024-02-29 00:00 UTC"); + assert_eq!(utc_date(1_791_208_980), "2026-10-05 14:03 UTC"); + // A year past 9999 keeps the plain zero-padded year (no `+`), as the old `{y:04}` did. + assert_eq!(utc_date(253_402_300_800), "10000-01-01 00:00 UTC"); + // Past chrono's own year range (262143) the instant is moved into range by whole 400-year + // cycles and the real year restored, so it keeps the old hand-rolled output instead of + // falling back to the epoch. + assert_eq!(utc_date(u64::MAX), "584554051223-11-09 07:00 UTC"); + } +} diff --git a/crates/color/src/lib.rs b/crates/color/src/lib.rs index d6859a51..84149dfb 100644 --- a/crates/color/src/lib.rs +++ b/crates/color/src/lib.rs @@ -61,7 +61,7 @@ impl<'de> Deserialize<'de> for Color { } fn clamp01(v: f32) -> f32 { - if v.is_finite() { v.clamp(0.0, 1.0) } else { 0.0 } + if v.is_nan() { 0.0 } else { v.clamp(0.0, 1.0) } } impl Default for Color { @@ -162,7 +162,13 @@ impl Color { p.parse::().ok().map(|v| v / 255.0) } }; - let alpha = parts.get(3).and_then(|a| parse_alpha(a)).unwrap_or(1.0); + let alpha = match parts.get(3) { + Some(a) => parse_alpha(a)?, + None => 1.0, + }; + if parts.len() > 4 { + return None; + } return Some(Self::new(num(0)?, num(1)?, num(2)?, alpha)); } if let Some(inner) = t.strip_prefix("hsla(").or_else(|| t.strip_prefix("hsl(")).and_then(|r| r.strip_suffix(')')) { @@ -170,7 +176,13 @@ impl Color { let h = parts.first()?.trim_end_matches("deg").parse::().ok()?; let s = parts.get(1)?.trim_end_matches('%').parse::().ok()? / 100.0; let l = parts.get(2)?.trim_end_matches('%').parse::().ok()? / 100.0; - let alpha = parts.get(3).and_then(|a| parse_alpha(a)).unwrap_or(1.0); + let alpha = match parts.get(3) { + Some(a) => parse_alpha(a)?, + None => 1.0, + }; + if parts.len() > 4 { + return None; + } return Some(Hsl { h, s, l }.to_color().with_alpha(alpha)); } if t == "transparent" { @@ -537,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, @@ -547,6 +559,17 @@ pub struct Paint { pub visible: bool, #[serde(default)] pub blend: BlendMode, + /// The opacity holds the alpha of a typed solid colour (see [`Paint::solid`]) rather than an + /// opacity set on its own: a colour variable bound to the paint replaces it. + #[serde(default, rename = "colorAlpha", skip_serializing_if = "std::ops::Not::not")] + pub color_alpha: bool, +} + +/// 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 { @@ -555,11 +578,17 @@ fn yes() -> bool { 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 < 1.0, + } } 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: false } } pub fn image(hash: impl Into, mode: ImageScaleMode) -> Self { @@ -575,12 +604,13 @@ impl Paint { opacity: 1.0, visible: true, blend: BlendMode::Normal, + color_alpha: false, } } /// 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: false } } /// The solid colour (with the paint opacity folded into alpha), if solid. diff --git a/crates/color/src/tests.rs b/crates/color/src/tests.rs index 68a838e0..2216b6fa 100644 --- a/crates/color/src/tests.rs +++ b/crates/color/src/tests.rs @@ -80,7 +80,7 @@ fn blend_names() { #[test] fn nan_inputs_are_sanitized() { let c = Color::new(f32::NAN, 2.0, -1.0, f32::INFINITY); - assert_eq!(c.to_rgba8(), [0, 255, 0, 0]); + assert_eq!(c.to_rgba8(), [0, 255, 0, 255]); } #[test] @@ -106,3 +106,21 @@ fn crop_defaults_to_fill_and_roundtrips() { assert!((a - b).abs() < 1e-9); } } + +#[test] +fn infinite_channels_clamp() { + let c = Color::parse_css("rgb(1e40, 0, 0)").unwrap(); + assert_eq!(c.r, 1.0); + let c = Color::new(f32::INFINITY, f32::NEG_INFINITY, f32::NAN, f32::INFINITY); + assert_eq!((c.r, c.g, c.b, c.a), (1.0, 0.0, 0.0, 1.0)); +} + +#[test] +fn malformed_alpha_or_extra_args_rejected() { + assert_eq!(Color::parse_css("rgba(0,0,0,abc)"), None); + assert_eq!(Color::parse_css("hsla(0,0%,0%,abc)"), None); + assert_eq!(Color::parse_css("rgb(1,2,3,4,5)"), None); + assert_eq!(Color::parse_css("hsl(0,0%,0%,0.5,1)"), None); + assert_eq!(Color::parse_css("rgb(1,2,3)").unwrap().a, 1.0); + assert_eq!(Color::parse_css("rgba(1,2,3,0.5)").unwrap().a, 0.5); +} 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 07700e8c..1eb1fb0b 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,10 @@ impl Document { return Err(DocError::NotContainer(parent)); } let new_root = *map.get(&root).ok_or(DocError::NoNode(root))?; + // Never overwrite an existing node with a copy. + if let Some(taken) = nodes.iter().filter_map(|n| map.get(&n.id)).find(|i| self.nodes.contains_key(*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 +855,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 @@ -949,56 +973,81 @@ impl Document { self.styles.iter().find(|s| s.id == id || s.name == id) } - /// The effective variable modes for a node (walking up its ancestors). + /// The effective variable modes for a node: the ones it sets over [`Self::inherited_modes`]. pub fn modes_for(&self, id: NodeId) -> BTreeMap { - let mut out = BTreeMap::new(); - let mut chain = vec![id]; - chain.extend(self.ancestors(id)); - for c in chain.into_iter().rev() { - if let Some(n) = self.node(c) { - for (k, v) in &n.var_modes { - out.insert(k.clone(), v.clone()); - } - } + let mut out = self.inherited_modes(id); + if let Some(n) = self.node(id) { + out.extend(n.var_modes.iter().map(|(k, v)| (k.clone(), v.clone()))); } out } + /// The modes a node is on where it sets none (Auto): its ancestors', and for an instance the + /// ones its main component sets, which are closer than any container's. + pub fn inherited_modes(&self, id: NodeId) -> BTreeMap { + let mut out = BTreeMap::new(); + for c in self.ancestors(id).into_iter().rev() { + if let Some(n) = self.node(c) { + out.extend(self.main_modes(n).chain(&n.var_modes).map(|(k, v)| (k.clone(), v.clone()))); + } + } + if let Some(n) = self.node(id) { + out.extend(self.main_modes(n).map(|(k, v)| (k.clone(), v.clone()))); + } + out + } + + /// The modes an instance's main component sets (none for other layers). + fn main_modes<'a>(&'a self, n: &Node) -> impl Iterator { + let main = match n.kind { + NodeKind::Instance { component, .. } => self.node(component), + _ => None, + }; + main.into_iter().flat_map(|m| &m.var_modes) + } + /// 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/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..38cab817 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,78 @@ 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()); +} + +#[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/doc/src/text.rs b/crates/doc/src/text.rs index 097dd189..458426ad 100644 --- a/crates/doc/src/text.rs +++ b/crates/doc/src/text.rs @@ -60,6 +60,9 @@ impl LetterSpacing { } pub fn trim_num(v: f64) -> String { + if v.abs() >= 9.0e18 { + return format!("{v}"); + } let r = (v * 100.0).round() / 100.0; if r.fract().abs() < 1e-9 { format!("{}", r as i64) } else { format!("{r}") } } @@ -760,6 +763,15 @@ impl TextData { mod case_tests { use super::*; + #[test] + fn trim_num_prints_huge_whole_numbers() { + assert_eq!(trim_num(1e19), "10000000000000000000"); + assert_eq!(trim_num(-1e19), "-10000000000000000000"); + assert_eq!(trim_num(12.0), "12"); + let v = 12345678901234567890.0_f64; + assert_eq!(trim_num(v).parse::().unwrap(), v); + } + #[test] fn title_case_respects_context() { assert_eq!(TextCase::Title.apply("hello wORLD don't 3d x-ray"), "Hello WORLD Don't 3d X-Ray"); diff --git a/crates/engine/src/a11y_keys_tests.rs b/crates/engine/src/a11y_keys_tests.rs index 6687886c..e4892bca 100644 --- a/crates/engine/src/a11y_keys_tests.rs +++ b/crates/engine/src/a11y_keys_tests.rs @@ -55,3 +55,51 @@ fn reduce_motion_is_a_preference_that_persists() { let old: crate::Prefs = serde_json::from_value(json!({"theme": "dark"})).unwrap(); assert!(!old.reduce_motion); } + +#[test] +fn interface_size_is_relative_to_the_system_and_persists() { + let mut s = Session::new(); + assert_eq!(s.prefs.ui_scale, 1.0, "the system's own size by default"); + assert_eq!(s.execute("pref.uiScale", &json!({})).unwrap()["scale"], 1.0); + assert_eq!(s.execute("pref.uiScale", &json!({"scale": 1.25})).unwrap()["scale"], 1.25); + assert_eq!(s.prefs.ui_scale, 1.25); + // Reading changes nothing; "auto" (older scripts) is the system's own size. + assert_eq!(s.execute("pref.uiScale", &json!({})).unwrap()["scale"], 1.25); + assert_eq!(s.execute("pref.uiScale", &json!({"scale": "Auto"})).unwrap()["scale"], 1.0); + // Nothing to undo: the document didn't change. + assert!(!s.ds().map(|d| d.is_dirty()).unwrap_or(false)); + // Saved with the other preferences and read back. + s.prefs.ui_scale = 0.9; + let v = serde_json::to_value(&s.prefs).unwrap(); + assert_eq!(v["ui_scale"], 0.9); + let back: crate::Prefs = serde_json::from_value(v).unwrap(); + assert_eq!(back.ui_scale, 0.9); +} + +#[test] +fn interface_size_stops_at_the_ends_and_junk_never_changes_it() { + let mut s = Session::new(); + assert_eq!(s.execute("pref.uiScale", &json!({"scale": 0.01})).unwrap()["scale"], crate::UI_SCALE_MIN); + assert_eq!(s.execute("pref.uiScale", &json!({"scale": -4})).unwrap()["scale"], crate::UI_SCALE_MIN); + assert_eq!(s.execute("pref.uiScale", &json!({"scale": 1e308})).unwrap()["scale"], crate::UI_SCALE_MAX); + // The size is a factor (1.25), not a percentage: 125 is far past the end and stops there. + assert_eq!(s.execute("pref.uiScale", &json!({"scale": 125})).unwrap()["scale"], crate::UI_SCALE_MAX); + s.prefs.ui_scale = 1.5; + for junk in [json!({"scale": "NaN"}), json!({"scale": "big"}), json!({"scale": [1]}), json!({"scale": {"x": 1}})] { + assert!(s.execute("pref.uiScale", &junk).is_err(), "{junk}"); + assert_eq!(s.prefs.ui_scale, 1.5, "{junk}"); + } +} + +#[test] +fn a_settings_file_without_or_with_a_wrong_interface_size_reads_as_the_systems_size_and_keeps_the_rest() { + let old: crate::Prefs = serde_json::from_value(json!({"theme": "dark"})).unwrap(); + assert_eq!(old.ui_scale, 1.0); + for bad in [json!("big"), json!(null), json!([2]), json!(true)] { + let p: crate::Prefs = serde_json::from_value(json!({"theme": "dark", "ui_scale": bad})).unwrap(); + assert_eq!((p.ui_scale, p.theme.as_str()), (1.0, "dark"), "{bad}"); + } + // A number out of range is clamped rather than refused. + let p: crate::Prefs = serde_json::from_value(json!({"ui_scale": 99, "theme": "dark"})).unwrap(); + assert_eq!((p.ui_scale, p.theme.as_str()), (crate::UI_SCALE_MAX, "dark")); +} 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 493c8f60..8b221efd 100644 --- a/crates/engine/src/binding_lists_tests.rs +++ b/crates/engine/src/binding_lists_tests.rs @@ -97,3 +97,77 @@ fn effect_moves_and_removals_reject_junk() { ex(&mut s, "effect.remove", json!({"ids": [r], "index": 7})); assert_eq!(s.doc().unwrap().node(r).unwrap().effects.len(), 1); } + +fn fill_hex(s: &Session, r: NodeId) -> Vec { + s.doc().unwrap().node(r).unwrap().fills.iter().filter_map(|p| p.solid_color()).map(|c| c.to_hex()).collect() +} + +fn brand_id(s: &Session) -> String { + s.doc().unwrap().variables.get("brand").unwrap().id.clone() +} + +#[test] +fn explicit_paint_edits_detach_the_colour_binding() { + // An edit that reported success keeps its colour: the binding it overwrote lets go (as + // selection colours already do), instead of putting the variable's colour back. + let edits = [ + ("fill.set", json!({"color": "#00FF00"})), + ("node.set", json!({"props": {"fill": "#00FF00"}})), + ("node.set", json!({"props": {"fills": ["#00FF00"]}})), + ]; + for (cmd, mut p) in edits { + let (mut s, r) = setup(); + ex(&mut s, "variable.bind", json!({"ids": [r], "field": "fill", "variable": "brand"})); + p["ids"] = json!([r]); + ex(&mut s, cmd, p.clone()); + assert_eq!(fill_hex(&s, r), ["00FF00"], "{cmd} {p}"); + assert!(bound(&s, r).is_empty(), "{cmd} {p}"); + ex(&mut s, "variable.set", json!({"variable": "brand", "value": "#123456"})); + assert_eq!(fill_hex(&s, r), ["00FF00"], "{cmd} {p}: no longer follows the variable"); + } + for (cmd, mut p) in [("stroke.set", json!({"color": "#00FF00"})), ("node.set", json!({"props": {"stroke": "#00FF00"}}))] { + let (mut s, r) = setup(); + ex(&mut s, "node.set", json!({"ids": [r], "props": {"strokes": ["#000000"]}})); + ex(&mut s, "variable.bind", json!({"ids": [r], "field": "stroke", "variable": "brand"})); + p["ids"] = json!([r]); + ex(&mut s, cmd, p); + let n = s.doc().unwrap().node(r).unwrap(); + assert_eq!(n.strokes[0].solid_color().map(|c| c.to_hex()).as_deref(), Some("00FF00"), "{cmd}"); + assert!(n.bound.is_empty(), "{cmd}"); + } +} + +#[test] +fn clearing_bound_fills_adds_no_paint_back() { + let (mut s, r) = setup(); + ex(&mut s, "variable.bind", json!({"ids": [r], "field": "fill", "variable": "brand"})); + ex(&mut s, "node.set", json!({"ids": [r], "props": {"fills": []}})); + assert!(s.doc().unwrap().node(r).unwrap().fills.is_empty()); + assert!(bound(&s, r).is_empty()); + ex(&mut s, "variable.set", json!({"variable": "brand", "value": "#123456"})); + assert!(s.doc().unwrap().node(r).unwrap().fills.is_empty(), "the variable doesn't bring the fill back"); +} + +#[test] +fn paint_opacity_edits_keep_the_colour_binding() { + let (mut s, r) = setup(); + ex(&mut s, "variable.bind", json!({"ids": [r], "field": "fill", "variable": "brand"})); + ex(&mut s, "fill.set", json!({"ids": [r], "opacity": 0.5})); + ex(&mut s, "variable.set", json!({"variable": "brand", "value": "#0000FF"})); + assert_eq!(fill_hex(&s, r), ["0000FF"], "the colour still follows the variable"); + assert_eq!(s.doc().unwrap().node(r).unwrap().fills[0].opacity, 0.5); + // The same colour with another opacity, through node.set, keeps it too. + ex(&mut s, "node.set", json!({"ids": [r], "props": {"fills": [{"type": "solid", "color": "#0000FF", "opacity": 0.25}]}})); + assert_eq!(bound(&s, r).len(), 1); + assert_eq!(s.doc().unwrap().node(r).unwrap().fills[0].opacity, 0.25); +} + +#[test] +fn paints_set_with_their_binding_in_the_same_call_keep_it() { + let (mut s, r) = setup(); + ex(&mut s, "variable.bind", json!({"ids": [r], "field": "fill", "variable": "brand"})); + let brand = brand_id(&s); + ex(&mut s, "node.set", json!({"ids": [r], "props": {"fills": ["#00FF00"], "boundVariables": {"fill": brand}}})); + assert_eq!(bound(&s, r), [("fill".to_string(), brand)]); + assert_eq!(fill_hex(&s, r), ["FF0000"], "the binding given with the paints applies"); +} 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/booleans.rs b/crates/engine/src/booleans.rs index 87d90153..c3de51cb 100644 --- a/crates/engine/src/booleans.rs +++ b/crates/engine/src/booleans.rs @@ -124,7 +124,7 @@ pub(crate) fn apply(s: &mut Session, cmd: &str, p: &Value, op: BoolOp) -> Result } return Ok(json!({"ids": ids, "operation": op})); } - let out = crate::cmd::structure::wrap_boolean(s, p, op)?; + let out = crate::cmd::structure::wrap_boolean(s, p, cmd, op)?; // Operands of a new group follow its operation. if let Some(g) = crate::params::id_of(&out["id"]) { let doc = s.doc_mut()?; diff --git a/crates/engine/src/bound_alpha_tests.rs b/crates/engine/src/bound_alpha_tests.rs new file mode 100644 index 00000000..c54f0fb7 --- /dev/null +++ b/crates/engine/src/bound_alpha_tests.rs @@ -0,0 +1,153 @@ +//! A colour variable's alpha applies once to the paint it is bound to (#802): a `#RRGGBBAA` fill keeps +//! its alpha in the paint opacity, and binding a variable with alpha hands the variable the whole +//! colour, so the two alphas don't multiply (canvas, SVG, `.fig` round trips, mode switches). + +use figcraft_doc::NodeId; +use serde_json::json; + +use crate::Session; + +/// 0x80 / 255 and 0x40 / 255. +const HALF: f32 = 128.0 / 255.0; +const QUARTER: f32 = 64.0 / 255.0; + +fn ex(s: &mut Session, cmd: &str, p: serde_json::Value) { + s.execute(cmd, &p).unwrap_or_else(|e| panic!("{cmd}: {e}")); +} + +/// A rectangle filled `#0D6EFD80` and a colour variable `tint` (Light `light`, Dark `dark`). +fn setup(light: &str, dark: &str) -> (Session, NodeId) { + let mut s = Session::new(); + let r = s.execute("shape.rectangle", &json!({"x": 0, "y": 0, "width": 10, "height": 10, "props": {"fill": "#0D6EFD80"}})).unwrap(); + let r = NodeId(r["id"].as_u64().unwrap()); + ex(&mut s, "variable.createCollection", json!({"name": "Tokens", "modes": ["Light", "Dark"]})); + ex(&mut s, "variable.create", json!({"collection": "Tokens", "name": "tint", "type": "color", "value": light})); + ex(&mut s, "variable.set", json!({"variable": "tint", "mode": "Dark", "value": dark})); + (s, r) +} + +/// The fill's effective alpha (colour alpha times paint opacity). +fn alpha(s: &Session, r: NodeId) -> f32 { + s.doc().unwrap().node(r).unwrap().fills[0].solid_color().unwrap().a +} + +/// The alpha byte of the rectangle rendered on its own (transparent backdrop). +fn pixel_alpha(s: &Session, r: NodeId) -> u8 { + let pix = figcraft_render::render_node(s.doc().unwrap(), r, 1.0).unwrap(); + let i = ((5 * pix.width() + 5) * 4 + 3) as usize; + pix.data()[i] +} + +fn near(a: f32, b: f32) -> bool { + (a - b).abs() < 2e-3 +} + +#[test] +fn a_hex_with_alpha_keeps_its_alpha_in_the_opacity() { + let (s, r) = setup("#0D6EFD80", "#0D6EFD40"); + let p = &s.doc().unwrap().node(r).unwrap().fills[0]; + assert_eq!(p.kind, figcraft_color::PaintKind::Solid { color: figcraft_color::Color::from_rgb8_const(0x0D, 0x6E, 0xFD) }); + assert!(near(p.opacity, HALF)); + assert_eq!(pixel_alpha(&s, r), 128); +} + +#[test] +fn a_bound_variable_alpha_applies_once() { + // The fill and the variable are both #0D6EFD80: the result is 50%, not 25%. + let (mut s, r) = setup("#0D6EFD80", "#0D6EFD40"); + ex(&mut s, "variable.bind", json!({"ids": [r.0], "field": "fill", "variable": "tint"})); + assert!(near(alpha(&s, r), HALF), "{}", alpha(&s, r)); + assert_eq!(pixel_alpha(&s, r), 128); + let svg = figcraft_svg::export_svg(s.doc().unwrap(), &[r], &figcraft_svg::SvgOptions::default()).unwrap(); + assert!(svg.contains("fill=\"#0D6EFD\" fill-opacity=\"0.502\""), "{svg}"); + // Binding the same variable again changes nothing. + ex(&mut s, "variable.bind", json!({"ids": [r.0], "field": "fill", "variable": "tint"})); + assert!(near(alpha(&s, r), HALF)); +} + +#[test] +fn unbinding_keeps_the_colour_with_its_alpha_in_the_opacity() { + let (mut s, r) = setup("#0D6EFD40", "#0D6EFD"); + ex(&mut s, "variable.bind", json!({"ids": [r.0], "field": "fill", "variable": "tint"})); + assert_eq!(pixel_alpha(&s, r), 64); + ex(&mut s, "variable.bind", json!({"ids": [r.0], "field": "fill", "variable": null})); + let p = &s.doc().unwrap().node(r).unwrap().fills[0]; + assert_eq!(p.kind, figcraft_color::PaintKind::Solid { color: figcraft_color::Color::from_rgb8_const(0x0D, 0x6E, 0xFD) }); + assert!(near(p.opacity, QUARTER), "{}", p.opacity); + assert_eq!(pixel_alpha(&s, r), 64); + // Undo restores the binding and its paint. + ex(&mut s, "edit.undo", json!({})); + assert!(!s.doc().unwrap().node(r).unwrap().bound.is_empty()); + assert_eq!(pixel_alpha(&s, r), 64); +} + +#[test] +fn modes_switch_the_alpha_without_accumulating() { + let (mut s, r) = setup("#0D6EFD40", "#0D6EFD80"); + ex(&mut s, "variable.bind", json!({"ids": [r.0], "field": "fill", "variable": "tint"})); + for (mode, want) in [("Dark", HALF), ("Light", QUARTER), ("Dark", HALF), ("Light", QUARTER)] { + ex(&mut s, "variable.setMode", json!({"collection": "Tokens", "mode": mode})); + assert!(near(alpha(&s, r), want), "{mode}: {}", alpha(&s, r)); + } + // An opacity set while bound multiplies on top and survives mode switches. + ex(&mut s, "fill.set", json!({"ids": [r.0], "index": 0, "opacity": 0.5})); + ex(&mut s, "variable.setMode", json!({"collection": "Tokens", "mode": "Dark"})); + assert!(near(alpha(&s, r), HALF * 0.5)); +} + +#[test] +fn node_set_and_style_bindings_apply_the_alpha_once() { + let (mut s, r) = setup("#0D6EFD80", "#0D6EFD40"); + let var = s.doc().unwrap().variables.get("tint").unwrap().id.clone(); + ex(&mut s, "node.set", json!({"id": r.0, "props": {"boundVariables": {"fill": var}}})); + assert!(near(alpha(&s, r), HALF)); + ex(&mut s, "node.set", json!({"id": r.0, "props": {"boundVariables": {"fill": null}}})); + assert!(near(s.doc().unwrap().node(r).unwrap().fills[0].opacity, HALF)); + // A paint style made from a `#RRGGBBAA` colour, bound to the variable. + let st = s.execute("style.create", &json!({"type": "paint", "name": "Tint", "value": "#0D6EFD80"})).unwrap()["id"].clone(); + ex(&mut s, "style.bind", json!({"style": st, "field": "fills/0/color", "variable": "tint"})); + ex(&mut s, "style.apply", json!({"ids": [r.0], "style": st})); + assert!(near(alpha(&s, r), HALF), "{}", alpha(&s, r)); +} + +#[test] +fn a_fig_round_trip_keeps_the_alpha_once() { + let (mut s, r) = setup("#0D6EFD80", "#0D6EFD40"); + ex(&mut s, "variable.bind", json!({"ids": [r.0], "field": "fill", "variable": "tint"})); + ex(&mut s, "fill.set", json!({"ids": [r.0], "index": 0, "opacity": 0.5})); + let (bytes, _) = figcraft_format::fig::export(s.doc().unwrap(), None).unwrap(); + let back = crate::cmd::file::load_fig(&bytes, "x").unwrap().unwrap().doc; + let n = back.nodes().find(|n| !n.bound.is_empty()).unwrap(); + let a = n.fills[0].solid_color().unwrap().a; + assert!(near(a, HALF * 0.5), "{a}"); +} + +#[test] +fn code_gives_a_translucent_stroke_its_alpha_once() { + let (mut s, r) = setup("#0D6EFD80", "#0D6EFD40"); + ex(&mut s, "stroke.add", json!({"ids": [r.0], "color": "#FF000080"})); + 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)); +} 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/arrange.rs b/crates/engine/src/cmd/arrange.rs index ad6c21d1..10ad13e5 100644 --- a/crates/engine/src/cmd/arrange.rs +++ b/crates/engine/src/cmd/arrange.rs @@ -27,6 +27,13 @@ pub fn specs() -> Vec { spacing ), spec!("arrange.tidy", "Tidy up", "{ids?, spacing?}", true, tidy), + spec!( + "arrange.reorder", + "Reorder selection", + "{ids?, source, target, swap?, fromGesture?} (insert at target's visual slot; no hierarchy changes)", + true, + reorder + ), spec!("arrange.packH", "Pack horizontal", "{ids?}", true, |s, p| pack(s, p, true)), spec!("arrange.packV", "Pack vertical", "{ids?}", true, |s, p| pack(s, p, false)), spec!("arrange.roundToPixels", "Round to pixels", "{ids?}", true, round_px), @@ -528,6 +535,7 @@ pub(crate) fn resize_cmd(s: &mut Session, p: &Value) -> Result { let (col, row) = string(p, "anchor").and_then(|a| crate::tools::anchor_of(&a)).unwrap_or((0, 0)); let (ax, ay) = (f64::from(col) / 2.0, f64::from(row) / 2.0); let doc = s.doc_mut()?; + let before = super::styles::bound_before(doc, &ids); for i in ids { let n = doc.get(i)?; let (ow, oh) = (n.width, n.height); @@ -551,6 +559,49 @@ pub(crate) fn resize_cmd(s: &mut Session, p: &Value) -> Result { } super::props::restore_worlds(doc, &kept)?; } + super::styles::detach_edited(doc, &before); + ok() +} + +/// Reorder visual slots without rebuilding or reparenting layers. +fn reorder(s: &mut Session, p: &Value) -> Result { + let cmd = "arrange.reorder"; + let ids = req_targets(cmd, s, p)?; + let source = req_id(cmd, p, "source")?; + let target = req_id(cmd, p, "target")?; + let ds = s.ds()?; + let base = ds.interaction.as_ref().filter(|_| boolean(p, "fromGesture") == Some(true)).map(|i| i.doc.clone()).unwrap_or_else(|| ds.doc.clone()); + let layout = + crate::selection_reorder::ReorderLayout::new(&base, &ids).ok_or_else(|| bad(cmd, "select a regular row, column or complete aligned grid"))?; + let positions = layout + .positions(source, target, boolean(p, "swap") == Some(true)) + .ok_or_else(|| bad(cmd, "source and target must belong to the selection"))?; + // Validate every delta before editing; absolute targets always come from the gesture's + // snapshot so crossing several slots and returning to the start does not accumulate edits. + let doc = s.doc()?; + let mut moves = Vec::new(); + for (id, at) in positions { + let now = doc.world_bounds(id).ok_or_else(|| bad(cmd, "layer is no longer available"))?; + let (dx, dy) = (at.x - now.x0, at.y - now.y0); + if !dx.is_finite() || !dy.is_finite() { + return Err(bad(cmd, "non-finite reorder result")); + } + if dx.abs() > 1e-9 || dy.abs() > 1e-9 { + let node = doc.get(id)?; + let parent = node.parent.map(|p| doc.world_transform(p)).unwrap_or(Affine::IDENTITY); + let transform = parent.inverse() * Affine::translate((dx, dy)) * parent * node.transform; + if !transform.as_coeffs().iter().all(|v| v.is_finite()) { + return Err(bad(cmd, "non-finite local transform")); + } + moves.push((id, dx, dy)); + } + } + if !moves.is_empty() { + let doc = s.doc_mut()?; + for (id, dx, dy) in moves { + move_world(doc, id, dx, dy)?; + } + } ok() } diff --git a/crates/engine/src/cmd/component.rs b/crates/engine/src/cmd/component.rs index ff7a56df..01914640 100644 --- a/crates/engine/src/cmd/component.rs +++ b/crates/engine/src/cmd/component.rs @@ -56,11 +56,17 @@ pub fn specs() -> Vec { s.set_selection(out.iter().filter_map(id_of).collect()); Ok(json!({"ids": out})) }), - spec!("component.combineVariants", "Combine as variants", "{ids? (components)}", true, combine), + spec!( + "component.combineVariants", + "Combine as variants", + "{ids? (main components; none already a variant, a library copy or inside an instance)} (wraps them in a new component set; names without `=` become \"Property 1=Default\", \"Property 1=VariantN\")", + true, + combine + ), spec!( "component.addVariant", "Add variant", - "{id? (component set or variant), property?, value?} (duplicates the variant; `property`=`value` names the new one)", + "{id? (component set, variant, or a main component not in a set yet: it becomes a set of itself and the new variant), property?, value?} (duplicates the variant; `property`=`value` names the new one)", true, add_variant ), @@ -159,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 ), @@ -330,23 +336,25 @@ fn create(s: &mut Session, p: &Value) -> Result { Ok(json!({"id": target})) } +/// The name a new component set takes from its first variant: the path before the last '/' +/// ("Button/State=Hover" → "Button"), or a plain name as is ("Button"). A bare "prop=value" name +/// ("State=Default, Size=Small") names no set. +fn set_name_from(variant: &str) -> Option { + let base = variant.rsplit_once('/').map_or(variant, |(path, _)| path).trim(); + (!base.is_empty() && !base.contains('=')).then(|| base.to_string()) +} + /// Wrap components in a new component set (named after the first) placed around them. -fn wrap_in_set(doc: &mut Document, ids: &[NodeId]) -> Result { - let cmd = "component.combineVariants"; +fn wrap_in_set(cmd: &str, doc: &mut Document, ids: &[NodeId]) -> Result { let top = *ids.last().ok_or_else(|| bad(cmd, "nothing selected"))?; let parent = doc.get(top)?.parent.ok_or_else(|| bad(cmd, "page"))?; if inside_instance(doc, top) || owning_instance(doc, parent).is_some() { - return Err(EngineError::Disabled(cmd.into(), "components inside an instance can't be combined".into())); + return Err(EngineError::Disabled(cmd.into(), INSIDE_INSTANCE.into())); } let bounds = doc.bounds_of(ids).unwrap_or_default(); let pw = doc.world_transform(parent); let local = figcraft_geom::transform_rect_bbox(pw.inverse(), bounds.inflate(20.0, 20.0)); - let base = ids - .first() - .and_then(|i| doc.node(*i)) - .map(|n| n.name.split(['/', '=']).next().unwrap_or("Component").trim().to_string()) - .filter(|n| !n.is_empty() && !n.contains(',')) - .unwrap_or_else(|| "Component".into()); + let base = ids.first().and_then(|i| doc.node(*i)).and_then(|n| set_name_from(&n.name)).unwrap_or_else(|| doc.next_name("Component")); let set = doc.alloc_id(); let mut n = Node::new(set, NodeKind::ComponentSet { description: String::new() }, base, local.width(), local.height()); n.transform = figcraft_geom::Affine::translate((local.x0, local.y0)); @@ -358,22 +366,71 @@ fn wrap_in_set(doc: &mut Document, ids: &[NodeId]) -> Result { Ok(set) } -fn combine(s: &mut Session, p: &Value) -> Result { - let cmd = "component.combineVariants"; - let ids = req_targets(cmd, s, p)?; - let doc = s.doc_mut()?; +const INSIDE_INSTANCE: &str = "components inside an instance can't become variants"; + +/// Why `ids` (distinct) can't become the variants of a new component set, or `None` when they +/// can: anything but main components, components that are variants already (combining them would +/// put a set in a set), library copies, components inside an instance, and a component inside +/// another one of `ids`. +fn combine_refusal(doc: &Document, ids: &[NodeId]) -> Option<&'static str> { if !ids.iter().all(|i| doc.node(*i).is_some_and(|n| matches!(n.kind, NodeKind::Component { .. }))) { - return Err(bad(cmd, "select components only")); + Some("select components only") + } else if ids.iter().any(|i| variant_set(doc, *i).is_some()) { + Some("components that are variants already can't be combined again") + } else if ids.iter().any(|i| doc.is_remote(*i)) { + Some(crate::library::READ_ONLY) + } else if ids.iter().any(|i| inside_instance(doc, *i)) { + Some(INSIDE_INSTANCE) + } else if roots(doc, ids).len() != ids.len() { + Some("a component can't be combined with one inside it") + } else { + None } - let ids = z_sorted(doc, &ids); - let set = wrap_in_set(doc, &ids)?; +} + +/// Whether "Combine as variants" is on offer for `ids` (menus and the Design panel): two or more +/// main components that [`combine_refusal`] lets through. +pub fn can_combine_variants(doc: &Document, ids: &[NodeId]) -> bool { + ids.len() >= 2 && combine_refusal(doc, ids).is_none() && ids.iter().collect::>().len() == ids.len() +} + +/// Whether "Add variant" is on offer for `id`: a component set with variants, a variant, or a main +/// component not in a set yet (it becomes one); never a library copy or inside an instance. +pub fn can_add_variant(doc: &Document, id: NodeId) -> bool { + let Some(n) = doc.node(id) else { return false }; + let local = !doc.is_remote(id) && !inside_instance(doc, id); + match n.kind { + NodeKind::ComponentSet { .. } => { + local && n.children.iter().any(|c| doc.node(*c).is_some_and(|c| matches!(c.kind, NodeKind::Component { .. }))) + } + NodeKind::Component { .. } => local, + _ => false, + } +} + +/// Wrap `ids` (z-sorted main components) in a new component set, naming them "Property 1=Default", +/// "Property 1=VariantN" unless already in prop=value form. Returns the set. +fn make_variant_set(cmd: &str, doc: &mut Document, ids: &[NodeId]) -> Result { + let set = wrap_in_set(cmd, doc, ids)?; for (k, i) in ids.iter().enumerate() { - // Name variants "Property 1=Variant N" unless already in prop=value form. let node = doc.get_mut(*i)?; if !node.name.contains('=') { node.name = format!("Property 1={}", if k == 0 { "Default".to_string() } else { format!("Variant{}", k + 1) }); } } + Ok(set) +} + +fn combine(s: &mut Session, p: &Value) -> Result { + let cmd = "component.combineVariants"; + let mut seen = BTreeSet::new(); + let ids: Vec = req_targets(cmd, s, p)?.into_iter().filter(|i| seen.insert(*i)).collect(); + let doc = s.doc_mut()?; + if let Some(why) = combine_refusal(doc, &ids) { + return Err(EngineError::Disabled(cmd.into(), why.into())); + } + let ids = z_sorted(doc, &ids); + let set = make_variant_set(cmd, doc, &ids)?; s.set_selection(vec![set]); Ok(json!({"id": set})) } @@ -397,15 +454,19 @@ fn fit_set(doc: &mut Document, set: NodeId) { fn add_variant(s: &mut Session, p: &Value) -> Result { let cmd = "component.addVariant"; - let t = targets(s, p).first().copied().ok_or_else(|| bad(cmd, "select a component set or variant"))?; + let t = targets(s, p).first().copied().ok_or_else(|| bad(cmd, "select a component set or component"))?; let doc = s.doc_mut()?; let (set, base) = match doc.get(t)?.kind { NodeKind::ComponentSet { .. } => { let last = doc.get(t)?.children.last().copied().ok_or_else(|| bad(cmd, "the component set is empty"))?; (t, last) } - NodeKind::Component { .. } => (variant_set(doc, t).ok_or_else(|| bad(cmd, "the component isn't a variant (combine as variants first)"))?, t), - _ => return Err(bad(cmd, "select a component set or variant")), + NodeKind::Component { .. } => match variant_set(doc, t) { + Some(set) => (set, t), + // A main component on its own becomes a set of itself, then gains the new variant. + None => (make_variant_set(cmd, doc, &[t])?, t), + }, + _ => return Err(bad(cmd, "select a component set or component")), }; let options = variant_options(doc, set); let mut pairs = variant_props(&doc.get(base)?.name); @@ -472,6 +533,7 @@ fn detach(s: &mut Session, p: &Value) -> Result { // Own layers lose their link (slot content stays as it is); nested instances stay // instances of their own mains. let nested = unlink_layers(doc, i, Some(i)); + keep_main_modes(doc, i)?; let n = doc.get_mut(i)?; n.kind = NodeKind::Frame; n.source = None; @@ -491,6 +553,19 @@ fn detach(s: &mut Session, p: &Value) -> Result { Ok(json!({"ids": out})) } +/// Make the variable modes instance `i` takes from its main its own (it's being detached). +fn keep_main_modes(doc: &mut Document, i: NodeId) -> Result<()> { + let modes = match doc.get(i)?.kind { + NodeKind::Instance { component, .. } => doc.node(component).map(|m| m.var_modes.clone()).unwrap_or_default(), + _ => return Ok(()), + }; + let own = &mut doc.get_mut(i)?.var_modes; + for (c, m) in modes { + own.entry(c).or_insert(m); + } + Ok(()) +} + fn reset_instance(s: &mut Session, p: &Value) -> Result { let cmd = "component.resetInstance"; let ids = req_targets(cmd, s, p)?; @@ -608,7 +683,7 @@ fn make_slot(s: &mut Session, p: &Value) -> Result { let owner = Owner::resolve(doc, id).ok_or_else(|| bad(cmd, "the frame isn't inside a component"))?; let name = string(p, "name").unwrap_or_else(|| fresh_prop_name(doc, &owner, &n.name)); let preferred = get(p, "preferred").cloned().unwrap_or(Value::Null); - add_prop(s, &json!({"id": id, "layer": id, "name": name, "type": "slot", "preferred": preferred})) + add_prop_for(cmd, s, &json!({"id": id, "layer": id, "name": name, "type": "slot", "preferred": preferred})) } fn remove_slot(s: &mut Session, p: &Value) -> Result { @@ -1039,7 +1114,12 @@ fn valid_prop_name(cmd: &str, name: &str) -> Result { } fn add_prop(s: &mut Session, p: &Value) -> Result { - let cmd = "component.addProperty"; + add_prop_for("component.addProperty", s, p) +} + +/// Add a component property for command `cmd`, which errors name (`component.makeSlot` adds its +/// slot property here). +fn add_prop_for(cmd: &str, s: &mut Session, p: &Value) -> Result { let target = targets(s, p).first().copied().ok_or_else(|| bad(cmd, "select a component"))?; let name = valid_prop_name(cmd, &req_string(cmd, p, "name")?)?; let t = req_string(cmd, p, "type")?; @@ -1084,7 +1164,7 @@ fn add_prop(s: &mut Session, p: &Value) -> Result { .unwrap_or_else(|| "Default".into()); let set = match owner { Owner::Set(s) => s, - Owner::Comp(c) => wrap_in_set(doc, &[c])?, + Owner::Comp(c) => wrap_in_set(cmd, doc, &[c])?, }; for c in Owner::Set(set).components(doc) { let cn = doc.get_mut(c)?; diff --git a/crates/engine/src/cmd/create.rs b/crates/engine/src/cmd/create.rs index 9b3f7067..33c611a9 100644 --- a/crates/engine/src/cmd/create.rs +++ b/crates/engine/src/cmd/create.rs @@ -73,9 +73,13 @@ pub fn specs() -> Vec { spec!("text.create", "Text", "{text, x, y, parent?, width? (fixed width wraps), props? (fontSize, fontFamily, …)}", true, |s, p| { create_kind(s, p, "text") }), - spec!("vector.create", "Vector", "{path (SVG path data) | points [[x,y],..], closed?, x?, y?, parent?, props?}", true, |s, p| create_kind( - s, p, "vector" - )), + spec!( + "vector.create", + "Vector", + "{path (SVG path data) | points [[x,y],..], closed?, x?, y?, width?, height? (scale the path; one keeps its proportion), parent?, props?}", + true, + |s, p| create_kind(s, p, "vector") + ), spec!( "image.place", "Place image", @@ -107,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 { .. }); @@ -162,6 +167,37 @@ pub fn parse_path_data(d: &str) -> Option { figcraft_geom::BezPath::from_svg(d).ok() } +/// How much a vector's path is scaled to take the size asked for: with both sides, each scales +/// independently (as resizing a vector does); with one, the other keeps its proportion. A size on +/// an axis the geometry has none of is an error unless it is 0 (a line's height), and a size of 0 +/// or less on an axis it has is refused rather than flattening it. +fn vector_scale(width: Option, height: Option, bw: f64, bh: f64, what: &str) -> std::result::Result<(f64, f64), String> { + let fit = |asked: Option, have: f64, axis: &str| match asked { + None => Ok(1.0), + Some(a) if have <= 1e-9 => { + if a == 0.0 { + Ok(1.0) + } else { + Err(format!("`{axis}` can't size {what} with no {} extent", if axis == "width" { "horizontal" } else { "vertical" })) + } + } + Some(a) if a > 0.0 && (a / have).is_finite() => Ok(a / have), + Some(_) => Err(format!("`{axis}` must be a positive, finite size: 0 or less would flatten {what}")), + }; + Ok(match (width, height) { + (Some(_), Some(_)) => (fit(width, bw, "width")?, fit(height, bh, "height")?), + (Some(_), None) => { + let s = fit(width, bw, "width")?; + (s, s) + } + (None, Some(_)) => { + let s = fit(height, bh, "height")?; + (s, s) + } + (None, None) => (1.0, 1.0), + }) +} + pub fn create(s: &mut Session, p: &Value) -> Result { let cmd = "node.create"; let t = string(p, "type").ok_or_else(|| bad(cmd, "`type` is required"))?; @@ -187,20 +223,32 @@ pub fn create(s: &mut Session, p: &Value) -> Result { "Section" => (num_or(p, "width", 400.0), num_or(p, "height", 300.0)), _ => (num_or(p, "width", 100.0), num_or(p, "height", 100.0)), }; - // Vectors: path data or points define the shape and its box. + // Vectors: path data or points define the shape and its box. `width`/`height`, when asked + // for, scale the geometry: both sides independently, one side proportionally. let mut offset = (0.0, 0.0); + let asked = (num(p, "width"), num(p, "height")); if let NodeKind::Vector { network } = &mut kind { if let Some(d) = string(p, "path") { let mut path = parse_path_data(&d).ok_or_else(|| bad(cmd, "`path` is not valid SVG path data"))?; use figcraft_geom::Shape; let bb = path.bounding_box(); + if bb.width() <= 1e-9 && bb.height() <= 1e-9 { + return Err(bad(cmd, "`path` draws nothing: a vector needs a visible line or area")); + } path.apply_affine(figcraft_geom::Affine::translate((-bb.x0, -bb.y0))); + let (sx, sy) = vector_scale(asked.0, asked.1, bb.width(), bb.height(), "a path").map_err(|e| bad(cmd, e))?; + if sx != 1.0 || sy != 1.0 { + path.apply_affine(figcraft_geom::Affine::scale_non_uniform(sx, sy)); + } *network = VectorNetwork::from_path(&path); + if network.segments.is_empty() { + return Err(bad(cmd, "`path` draws nothing: a vector needs a visible line or area")); + } if boolean(p, "closed") == Some(false) { network.regions.clear(); } - w = bb.width(); - h = bb.height(); + w = bb.width() * sx; + h = bb.height() * sy; offset = (bb.x0, bb.y0); } else if let Some(Value::Array(pts)) = get(p, "points") { let pts: Vec = pts.iter().filter_map(|v| Some(Point::new(f64_of(v.get(0)?)?, f64_of(v.get(1)?)?))).collect(); @@ -208,10 +256,14 @@ pub fn create(s: &mut Session, p: &Value) -> Result { return Err(bad(cmd, "`points` needs at least two [x, y] pairs")); } let bb = figcraft_geom::points_bounds(pts.iter().copied()).unwrap_or_default(); - let local: Vec = pts.iter().map(|q| Point::new(q.x - bb.x0, q.y - bb.y0)).collect(); + if bb.width() <= 1e-9 && bb.height() <= 1e-9 { + return Err(bad(cmd, "`points` draws nothing: a vector needs a visible line or area")); + } + let (sx, sy) = vector_scale(asked.0, asked.1, bb.width(), bb.height(), "a point set").map_err(|e| bad(cmd, e))?; + let local: Vec = pts.iter().map(|q| Point::new((q.x - bb.x0) * sx, (q.y - bb.y0) * sy)).collect(); *network = VectorNetwork::from_points(&local, boolean(p, "closed").unwrap_or(false)); - w = bb.width(); - h = bb.height(); + w = bb.width() * sx; + h = bb.height() * sy; offset = (bb.x0, bb.y0); } } diff --git a/crates/engine/src/cmd/edit.rs b/crates/engine/src/cmd/edit.rs index a4769a35..60a96b31 100644 --- a/crates/engine/src/cmd/edit.rs +++ b/crates/engine/src/cmd/edit.rs @@ -18,13 +18,19 @@ pub fn specs() -> Vec { spec!( "edit.duplicate", "Duplicate", - "{ids?, dx?, dy? (page offset; default: the offset of the last duplicate when ids are its copies, still selected; else top-level frames to the right with a 100 gap, everything else in place)}", + "{ids?, dx?, dy? (page offset; default: the offset of the last duplicate when ids are its copies, still selected; else top-level frames to the right with a 100 gap, everything else in place)} a main component's copy is an instance of it (a variant stays a new variant in its set)", true, duplicate ), spec!("edit.copy", "Copy", "{ids?}", false, copy), spec!("edit.cut", "Cut", "{ids?}", true, cut), - spec!("edit.paste", "Paste", "{parent?, x?, y? (page coordinates of the pasted top-left)}", true, paste), + spec!( + "edit.paste", + "Paste", + "{parent?, x?, y? (page coordinates of the pasted top-left)} a copied main component pastes as an instance of it within its file (cut ones, and pastes into other files, keep the component)", + true, + paste + ), spec!( "edit.pasteDesignLayers", "Paste design layers", @@ -261,7 +267,7 @@ pub fn specs() -> Vec { }), spec!("effect.set", "Set effect", "{ids?, index, effect {..}}", true, |s, p| { let i = req_index("effect.set", p, "index")?; - let e: Effect = parse("effect.set", p, "effect")?.ok_or_else(|| bad("effect.set", "`effect` required"))?; + let e = super::props::effect_from("effect.set", p, "effect")?.ok_or_else(|| bad("effect.set", "`effect` required"))?; for id in req_targets("effect.set", s, p)? { let n = s.doc_mut()?.get_mut(id)?; if let Some(slot) = n.effects.get_mut(i) { @@ -466,7 +472,7 @@ fn duplicate(s: &mut Session, p: &Value) -> Result { let top_frame = doc.node(parent).is_some_and(Node::is_page) && n.kind.is_frame_like(); let idx = doc.index_in_parent(i).map(|x| x + 1); let w = n.width; - let nid = doc.duplicate_into(i, parent, idx)?; + let nid = crate::components::copy_in_file(doc, i, parent, idx)?; let (dx, dy) = match (num(p, "dx"), num(p, "dy")) { (Some(dx), dy) => (dx, dy.unwrap_or(0.0)), (None, Some(dy)) => (0.0, dy), @@ -481,12 +487,16 @@ fn duplicate(s: &mut Session, p: &Value) -> Result { nn.transform = Affine::translate((dx, dy)) * nn.transform; } } - if rename { + // An instance keeps the name instances get by default. + if rename && !matches!(doc.get(nid)?.kind, NodeKind::Instance { .. }) { let nn = doc.get_mut(nid)?; nn.name = bump_name(&nn.name); } 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})) @@ -515,6 +525,13 @@ pub fn copy_to_clipboard(s: &mut Session, cmd: &str, ids: &[NodeId]) -> Result<( s.clipboard.source = Some(source); s.clipboard.source_path = source_path; s.clipboard.source_doc = uid; + s.clipboard.copied_components = nodes + .iter() + .filter(|(_, sub)| { + cmd == "edit.copy" && sub.first().is_some_and(|n| matches!(n.kind, NodeKind::Component { .. } | NodeKind::ComponentSet { .. })) + }) + .map(|(root, _)| *root) + .collect(); s.clipboard.nodes = nodes; s.clipboard.images = images; s.clipboard.bounds = bounds; @@ -593,7 +610,8 @@ fn paste_design_layers(s: &mut Session, p: &Value) -> Result { let (nodes, images, bounds) = copied(&doc, &tops); let props = s.clipboard.props.take(); // No open file has id 0. So paste treats this copy as a copy from a different file. - s.clipboard = crate::Clipboard { nodes, images, props, bounds, source_doc: 0, source: Some(doc), source_path: None }; + s.clipboard = + crate::Clipboard { nodes, images, props, bounds, source_doc: 0, source: Some(doc), source_path: None, copied_components: BTreeSet::new() }; let mut out = paste(s, p)?; if !other.issues.is_empty() { out["warnings"] = json!(other.issues); @@ -623,6 +641,7 @@ pub fn paste_into(s: &mut Session, parent: NodeId, at: Option<(f64, f64)>) -> Re } // From another file: its instances follow the mains this file can have. let foreign = clip.source.as_ref().filter(|_| s.ds().is_ok_and(|d| d.uid != clip.source_doc)); + let copy_local = s.ds()?.uid == clip.source_doc; let doc = s.doc_mut()?; for (k, v) in &clip.images { doc.images.entry(k.clone()).or_insert_with(|| v.clone()); @@ -636,6 +655,16 @@ pub fn paste_into(s: &mut Session, parent: NodeId, at: Option<(f64, f64)>) -> Re let mut out = Vec::new(); let mut all = BTreeMap::new(); for (root, nodes) in &clip.nodes { + if copy_local + && clip.copied_components.contains(root) + && let Some(n) = nodes.first().filter(|n| matches!(n.kind, NodeKind::Component { .. })) + && doc.node(*root).is_some_and(|n| matches!(n.kind, NodeKind::Component { .. })) + { + let instance = crate::components::create_instance(doc, *root, parent, 0.0, 0.0)?; + doc.get_mut(instance)?.transform = doc.world_transform(parent).inverse() * shift * n.transform; + out.push(instance); + continue; + } let mut map = BTreeMap::new(); for n in nodes { map.insert(n.id, doc.alloc_id()); @@ -649,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)?; } @@ -661,10 +694,15 @@ fn paste(s: &mut Session, p: &Value) -> Result { // else in the frame's centre; otherwise in place on the page (or the viewport centre). let sel = s.selection(); let doc = s.doc()?; + let copy_local = s.ds()?.uid == s.clipboard.source_doc; 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); let at = match (num(p, "x"), num(p, "y")) { @@ -931,9 +969,13 @@ pub fn opacity_from_digits(digits: &str) -> Option { fn opacity_keys(s: &mut Session, p: &Value) -> Result { let digits = req_string("object.opacityKeys", p, "digits")?; let o = opacity_from_digits(&digits).ok_or_else(|| bad("object.opacityKeys", "`digits` must be one or two digits"))?; - for i in req_targets("object.opacityKeys", s, p)? { - s.doc_mut()?.get_mut(i)?.opacity = o; + let ids = req_targets("object.opacityKeys", s, p)?; + let doc = s.doc_mut()?; + let before = super::styles::bound_before(doc, &ids); + for i in ids { + doc.get_mut(i)?.opacity = o; } + super::styles::detach_edited(doc, &before); Ok(json!({"opacity": o})) } @@ -1181,10 +1223,12 @@ fn set(s: &mut Session, p: &Value) -> Result { } } let doc = s.doc_mut()?; + let before = super::styles::bound_before(doc, &ids); for i in ids { let props = if skipped.contains(&i) { &without_layout } else { &props }; super::props::apply("node.set", doc, i, props)?; } + super::styles::detach_edited(doc, &before); if let Some(o) = pivot { for (i, q) in pinned { let d = o - doc.world_transform(i) * q; @@ -1331,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 = true; } } 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 = false; } if let Some(v) = boolean(p, "visible") { slot.visible = v; @@ -1342,7 +1388,7 @@ fn paint_set(s: &mut Session, p: &Value, fills: bool) -> Result { if let Some(b) = blend(p, "blend") { slot.blend = b; } - super::lists::remap_stop_bindings(n, fills, at, &old); + super::lists::rebind_edited_paint(n, fills, at, &old); if fills { n.styles.fill = None; } else { @@ -1376,8 +1422,8 @@ fn pattern_fill(s: &mut Session, p: &Value) -> Result { } fn effect_add(s: &mut Session, p: &Value) -> Result { - let e: Effect = match get(p, "effect") { - Some(v) => serde_json::from_value(v.clone()).map_err(|e| bad("effect.add", e.to_string()))?, + let e: Effect = match super::props::effect_from("effect.add", p, "effect")? { + Some(e) => e, None => { let ty = string(p, "type").unwrap_or_else(|| "dropShadow".into()); Effect::of_type(&ty).ok_or_else(|| bad("effect.add", format!("unknown effect type `{ty}`")))? diff --git a/crates/engine/src/cmd/file.rs b/crates/engine/src/cmd/file.rs index 511e0fc6..5944d701 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", @@ -92,6 +104,20 @@ pub fn specs() -> Vec { ), spec!("file.placeImage", "Place image", "{path | data, x?, y?}", true, super::create::place_image), spec!("file.close", "Close tab", "{index?, force? (discard unsaved changes)}", false, close), + spec!("file.moveTab", "Move tab", "{from, to} (indices among open documents)", false, |s, p| { + let from = req_index("file.moveTab", p, "from")?; + let to = req_index("file.moveTab", p, "to")?; + if from >= s.docs.len() || to >= s.docs.len() { + return Err(bad("file.moveTab", "no such document")); + } + if from != to { + let active_uid = s.docs.get(s.active).map(|d| d.uid); + let doc = s.docs.remove(from); + s.docs.insert(to, doc); + s.active = active_uid.and_then(|uid| s.docs.iter().position(|d| d.uid == uid)).unwrap_or(0); + } + Ok(json!({"index": to})) + }), spec!( "file.recover", "Recover file", @@ -352,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))) } @@ -528,7 +554,8 @@ fn load_with( return Ok((figcraft_format::Loaded { issues: warnings, ..doc.into() }, true)); } let mut loaded = figcraft_format::load_bytes_with_versions(bytes).map_err(|e| EngineError::Other(e.to_string()))?; - if loaded.doc.name.is_empty() { + // Older files saved an untouched default title even after the user chose a filename. + if loaded.doc.name.is_empty() || loaded.doc.name == "Untitled" { loaded.doc.name = file_stem(name_hint); } Ok((loaded, false)) @@ -548,6 +575,7 @@ pub(crate) fn load_fig(bytes: &[u8], name: &str) -> Option = @@ -838,8 +866,14 @@ fn save_to(s: &mut Session, path: String) -> Result { } let i = crate::branches::file_tab(s); let ds = s.docs.get_mut(i).ok_or(EngineError::NoDocument)?; - let bytes = file_bytes(ds)?; + let mut snapshot = FileSnapshot::of(ds); + let follows_path = ds.path.as_deref().map_or(ds.doc.name == "Untitled", |old| ds.doc.name == file_stem(old)); + if follows_path && ds.path.as_deref() != Some(path.as_str()) { + Arc::make_mut(&mut snapshot.doc).name = file_stem(&path); + } + let bytes = snapshot.encode()?; write(&path, &bytes)?; + ds.doc = snapshot.doc; ds.path = Some(path.clone()); ds.mark_saved(); ds.revision += 1; @@ -1147,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() } @@ -1373,14 +1410,36 @@ fn close(s: &mut Session, p: &Value) -> Result { return Err(EngineError::Disabled("file.close".into(), "the document has unsaved changes (pass force: true to discard)".into())); } let uid = ds.uid; + let active_uid = s.docs.get(s.active).map(|d| d.uid); s.docs.remove(i); - if s.active >= s.docs.len() { - s.active = s.docs.len().saturating_sub(1); - } + // Removing a tab before the active one shifts its index; keep the same document selected. + s.active = active_uid.and_then(|a| s.docs.iter().position(|d| d.uid == a)).unwrap_or(i.min(s.docs.len().saturating_sub(1))); crate::branches::close_tabs(s, uid, None); ok() } +#[cfg(test)] +mod tab_order_tests { + use super::*; + + #[test] + fn moving_and_closing_inactive_tabs_preserves_the_active_document() { + let mut session = Session::new(); + for name in ["Two", "Three", "Four"] { + session.execute("file.new", &json!({"name": name})).unwrap(); + } + session.execute("doc.switch", &json!({"index": 2})).unwrap(); + let active_uid = session.docs[session.active].uid; + let moved_uid = session.docs[0].uid; + session.execute("file.moveTab", &json!({"from": 0, "to": 2})).unwrap(); + assert_eq!(session.docs[2].uid, moved_uid); + assert_eq!(session.docs[session.active].uid, active_uid); + session.execute("file.close", &json!({"index": 0})).unwrap(); + assert_eq!(session.docs[session.active].uid, active_uid); + assert!(session.execute("file.moveTab", &json!({"from": 0, "to": 99})).is_err()); + } +} + /// Ctrl+Tab / Ctrl+Shift+Tab: the next or previous open document, wrapping around. fn cycle_doc(s: &mut Session, dir: i64) -> Result { let n = s.docs.len() as i64; diff --git a/crates/engine/src/cmd/layout.rs b/crates/engine/src/cmd/layout.rs index 5643ac70..da1301ec 100644 --- a/crates/engine/src/cmd/layout.rs +++ b/crates/engine/src/cmd/layout.rs @@ -149,6 +149,35 @@ mod tests { assert!(s.execute("node.set", &json!({"id": b, "props": {"absolute": "maybe"}})).is_err()); } + /// Each side key writes its own padding slot and leaves the other three alone. + #[test] + fn each_side_padding_sets_its_side_alone() { + let mut s = Session::new(); + let (f, _, _) = al_frame(&mut s); + // One side at a time: the other three stay 0 ([top, right, bottom, left]). + for (key, v, want) in [ + ("paddingTop", 8.0, [8.0, 0.0, 0.0, 0.0]), + ("paddingRight", 12.0, [0.0, 12.0, 0.0, 0.0]), + ("paddingBottom", 16.0, [0.0, 0.0, 16.0, 0.0]), + ("paddingLeft", 20.0, [0.0, 0.0, 0.0, 20.0]), + ] { + ex(&mut s, "node.set", json!({"id": f, "props": {"padding": 0}})); + ex(&mut s, "node.set", json!({"id": f, "props": {key: v}})); + assert_eq!(node(&s, f).layout.as_ref().unwrap().padding, want, "{key}"); + } + // The [t, r, b, l] array and the axis pairs agree with the four sides. + ex(&mut s, "node.set", json!({"id": f, "props": {"padding": [8, 12, 16, 20]}})); + assert_eq!(node(&s, f).layout.as_ref().unwrap().padding, [8.0, 12.0, 16.0, 20.0]); + ex(&mut s, "node.set", json!({"id": f, "props": {"paddingHorizontal": 4}})); + assert_eq!(node(&s, f).layout.as_ref().unwrap().padding, [8.0, 4.0, 16.0, 4.0]); + ex(&mut s, "node.set", json!({"id": f, "props": {"paddingVertical": 2}})); + assert_eq!(node(&s, f).layout.as_ref().unwrap().padding, [2.0, 4.0, 2.0, 4.0]); + // Negatives clamp to 0; junk is refused. + ex(&mut s, "node.set", json!({"id": f, "props": {"paddingTop": -5}})); + assert_eq!(node(&s, f).layout.as_ref().unwrap().padding[0], 0.0); + assert!(s.execute("node.set", &json!({"id": f, "props": {"paddingLeft": "wide"}})).is_err()); + } + #[test] fn baseline_alignment_lines_up_text() { let mut s = Session::new(); diff --git a/crates/engine/src/cmd/lists.rs b/crates/engine/src/cmd/lists.rs index 769d0a29..287c9ddd 100644 --- a/crates/engine/src/cmd/lists.rs +++ b/crates/engine/src/cmd/lists.rs @@ -112,7 +112,8 @@ pub fn stop_binding(field: &str) -> Option { /// Keep the gradient stop bindings of paint `index` of `n`'s fills (or strokes) on their stops after /// the paint changed from `old`: one stop removed shifts the later ones down (the removed one's /// binding goes); a paint that is no longer a gradient drops them. Other edits keep stop indices. -pub(crate) fn remap_stop_bindings(n: &mut Node, fills: bool, index: usize, old: &figcraft_color::Paint) { +/// Returns the removed stop's index. +pub(crate) fn remap_stop_bindings(n: &mut Node, fills: bool, index: usize, old: &figcraft_color::Paint) -> Option { use figcraft_color::PaintKind; let list = if fills { BoundList::Fills } else { BoundList::Strokes }; let new = if fills { n.fills.get(index) } else { n.strokes.get(index) }; @@ -122,11 +123,11 @@ pub(crate) fn remap_stop_bindings(n: &mut Node, fills: bool, index: usize, old: // The first stop that differs is the removed one when the rest moved down unchanged. let k = a.stops.iter().zip(&b.stops).position(|(x, y)| x != y).unwrap_or(b.stops.len()); if a.stops.get(k + 1..) != b.stops.get(k..) { - return; + return None; } Some(k) } - (_, Some(PaintKind::Gradient(_))) => return, + (_, Some(PaintKind::Gradient(_))) => return None, _ => None, }; let keys: Vec<(String, Option)> = n @@ -150,6 +151,42 @@ pub(crate) fn remap_stop_bindings(n: &mut Node, fills: bool, index: usize, old: } } n.bound.extend(kept); + removed +} + +/// After an explicit edit changed paint `index` of `n`'s fills (or strokes) from `old`: its stop +/// bindings follow a removed stop, then each binding whose value the edit changed (the colour, the +/// opacity, a stop's colour) lets go, so the edit stays instead of being put back from the +/// variable. Bindings whose value the edit kept stay; a paint that is gone takes its bindings. +pub(crate) fn rebind_edited_paint(n: &mut Node, fills: bool, index: usize, old: &figcraft_color::Paint) { + let removed = remap_stop_bindings(n, fills, index, old); + let mut old = old.clone(); + if let (Some(k), figcraft_color::PaintKind::Gradient(g)) = (removed, &mut old.kind) + && k < g.stops.len() + { + g.stops.remove(k); + } + let list = if fills { BoundList::Fills } else { BoundList::Strokes }; + let new = if fills { n.fills.get(index) } else { n.strokes.get(index) }.cloned(); + n.bound.retain(|key, _| match list_binding(key) { + Some((l, i, field)) if l == list && i == index => new.as_ref().is_some_and(|new| paint_field_kept(&old, new, field)), + _ => true, + }); +} + +/// Whether a paint's bound `field` (`color`, `opacity`, `stops//color`, …) has the same value in +/// `new` as in `old`. +fn paint_field_kept(old: &figcraft_color::Paint, new: &figcraft_color::Paint, field: &str) -> bool { + use figcraft_color::PaintKind; + match field { + "color" => matches!((&old.kind, &new.kind), (PaintKind::Solid { color: a }, PaintKind::Solid { color: b }) if a == b), + "opacity" => old.opacity == new.opacity, + _ => match (stop_binding(field), &old.kind, &new.kind) { + (Some(j), PaintKind::Gradient(a), PaintKind::Gradient(b)) => a.stops.get(j).map(|s| s.color) == b.stops.get(j).map(|s| s.color), + (Some(_), _, _) => false, + _ => old == new, + }, + } } /// Where item `k` lands when the item at `from` moves to `to`. 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 ca62962d..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), \ @@ -61,6 +61,48 @@ pub fn snake(v: &Value) -> Value { } } +/// `v` as an effect list, refusing keys an effect doesn't read: serde dropped them without a word, +/// so a shadow given `offsetY` kept the default offset. +pub fn effects_from(cmd: &str, v: &Value) -> Result> { + let effects: Vec = serde_json::from_value(snake(v)).map_err(|e| bad(cmd, format!("effects: {e}")))?; + for (i, given) in v.as_array().into_iter().flatten().enumerate() { + refuse_ignored_effect_keys(cmd, &format!("effects[{i}]"), given, |v| serde_json::from_value(snake(v)).ok())?; + } + Ok(effects) +} + +/// `p[key]` as one effect, read as serde reads it (its aliases too), refusing keys it doesn't read. +pub fn effect_from(cmd: &str, p: &Value, key: &str) -> Result> { + let Some(v) = get(p, key) else { return Ok(None) }; + let effect: Effect = serde_json::from_value(v.clone()).map_err(|e| bad(cmd, format!("`{key}`: {e}")))?; + refuse_ignored_effect_keys(cmd, &format!("`{key}`"), v, |v| serde_json::from_value(v.clone()).ok())?; + Ok(Some(effect)) +} + +/// Refuse the keys of effect `given` that `parse` ignores, naming the keys the effect takes. Set to +/// null, a key it reads fails to parse or changes the effect; a key it ignores changes nothing. +fn refuse_ignored_effect_keys(cmd: &str, at: &str, given: &Value, parse: impl Fn(&Value) -> Option) -> Result<()> { + let Some(fields) = given.as_object() else { return Ok(()) }; + let parsed = parse(given); + let ignored: Vec<&str> = fields + .iter() + .filter(|(_, v)| !v.is_null()) + .filter(|(k, _)| { + let mut probe = fields.clone(); + probe.insert((*k).clone(), Value::Null); + parse(&Value::Object(probe)).is_some_and(|e| Some(e) == parsed) + }) + .map(|(k, _)| k.as_str()) + .collect(); + if ignored.is_empty() { + return Ok(()); + } + let takes = parsed.and_then(|e| serde_json::to_value(e).ok()).map(crate::inspect::camel).unwrap_or_default(); + let kind = takes.get("type").and_then(Value::as_str).unwrap_or("effect").to_string(); + let names: Vec<&str> = takes.as_object().into_iter().flatten().map(|(k, _)| k.as_str()).collect(); + Err(bad(cmd, format!("{at}: unknown properties: {} (a {kind} takes {})", ignored.join(", "), names.join(", ")))) +} + pub(crate) fn enum_of(v: &Value) -> Option { let s = v.as_str()?; // Accept camelCase, snake/upper-case and spaced variants. @@ -297,6 +339,12 @@ pub const LAYOUT_KEYS: &[&str] = &[ "gridRowTracks", ]; +/// Drop the bindings of auto layout fields (gap, padding…): they go with the auto layout, or +/// re-applying them would bring it back. +pub fn drop_layout_bindings(n: &mut Node) { + n.bound.retain(|k, _| !LAYOUT_KEYS.contains(&k.as_str())); +} + /// The auto layout of a frame-like node, created when it has none yet. Other nodes can't hold an /// auto layout: an auto layout key on them is refused, not stored where nothing reads it /// (`node.set` on several layers skips them instead, see `edit::set`). @@ -315,18 +363,39 @@ fn layout_mut<'a>(cmd: &str, k: &str, n: &'a mut Node) -> Result<&'a mut AutoLay Ok(ensure_layout(n)) } -/// Apply a property map to one node. `x`/`y` are relative to the parent. -pub fn apply(cmd: &str, doc: &mut Document, id: NodeId, props: &Map) -> Result<()> { - let mut unknown = Vec::new(); - // Geometry keys first so rotation applies to the final size. - let order = |k: &str| match k { +/// Replace a node's fills (or strokes). Bindings on the paints whose bound values the new list +/// changed let go, as an explicit edit does (see `lists::rebind_edited_paint`). +fn set_paints(n: &mut Node, fills: bool, paints: Vec) { + let old = std::mem::replace(if fills { &mut n.fills } else { &mut n.strokes }, paints); + for (i, p) in old.iter().enumerate() { + super::lists::rebind_edited_paint(n, fills, i, p); + } +} + +/// `"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, + "fillStyle" | "strokeStyle" | "effectStyle" | "textStyle" | "gridStyle" => 4, + "boundVariables" => 5, _ => 1, - }; + } +} + +/// Apply a property map to one node. `x`/`y` are relative to the parent. +pub fn apply(cmd: &str, doc: &mut Document, id: NodeId, props: &Map) -> Result<()> { + let mut unknown = Vec::new(); let mut keys: Vec<(&String, &Value)> = props.iter().collect(); - keys.sort_by_key(|(k, _)| order(k)); + keys.sort_by_key(|(k, _)| apply_order(k)); for (k, v) in keys { let k = k.as_str(); match k { @@ -398,15 +467,23 @@ pub fn apply(cmd: &str, doc: &mut Document, id: NodeId, props: &Map doc.get_mut(id)?.fills = paints_of(v).ok_or_else(|| bad(cmd, "`fills` must be paints or colours"))?, + "fills" => 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"))?; - doc.get_mut(id)?.fills = 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" => doc.get_mut(id)?.strokes = paints_of(v).ok_or_else(|| bad(cmd, "`strokes` must be paints or colours"))?, + "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"))?; - doc.get_mut(id)?.strokes = 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"))?, @@ -462,7 +539,7 @@ pub fn apply(cmd: &str, doc: &mut Document, id: NodeId, props: &Map doc.get_mut(id)?.corners.smoothing = f64_of(v).unwrap_or(0.0).clamp(0.0, 1.0), - "effects" => doc.get_mut(id)?.effects = serde_json::from_value(snake(v)).map_err(|e| bad(cmd, format!("effects: {e}")))?, + "effects" => doc.get_mut(id)?.effects = effects_from(cmd, v)?, "clip" | "clipsContent" => doc.get_mut(id)?.clip = v.as_bool().ok_or_else(|| bad(cmd, "`clip` must be a boolean"))?, "isMask" => doc.get_mut(id)?.is_mask = v.as_bool().unwrap_or(false), "devStatus" => { @@ -471,8 +548,12 @@ pub fn apply(cmd: &str, doc: &mut Document, id: NodeId, props: &Map n.layout = None, - Value::String(s) if s.eq_ignore_ascii_case("none") => n.layout = None, + Value::Null => { + n.layout = None; + drop_layout_bindings(n); + } + Value::String(s) if s.eq_ignore_ascii_case("none") => { + n.layout = None; + drop_layout_bindings(n); + } Value::String(s) => { let mode = match s.to_ascii_lowercase().as_str() { "horizontal" | "row" => LayoutMode::Horizontal, @@ -775,10 +862,15 @@ pub fn apply(cmd: &str, doc: &mut Document, id: NodeId, props: &Map { + if n.bound.get(field).map(String::as_str) != Some(vid) { + crate::cmd::styles::rebind_paint_alpha(&mut n.fills, &mut n.strokes, field, true); + } n.bound.insert(field.clone(), vid.to_string()); } None => { - n.bound.remove(field); + if n.bound.remove(field).is_some() { + crate::cmd::styles::rebind_paint_alpha(&mut n.fills, &mut n.strokes, field, false); + } } } } diff --git a/crates/engine/src/cmd/section.rs b/crates/engine/src/cmd/section.rs index ac7fb1ee..08a5a7cc 100644 --- a/crates/engine/src/cmd/section.rs +++ b/crates/engine/src/cmd/section.rs @@ -34,7 +34,7 @@ pub fn specs() -> 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 1c86f932..700825f2 100644 --- a/crates/engine/src/cmd/structure.rs +++ b/crates/engine/src/cmd/structure.rs @@ -10,11 +10,13 @@ use super::{CommandSpec, ok}; use crate::params::*; use crate::{EngineError, Result, Session, bad, spec}; +/// The layer structure commands: group, frame, mask, boolean operations, flatten, auto layout +/// add/remove, move to page and vector path edits. pub fn specs() -> Vec { vec![ - spec!("object.group", "Group selection", "{ids?, name?}", true, |s, p| wrap(s, p, Wrap::Group)), + spec!("object.group", "Group selection", "{ids?, name?}", true, |s, p| wrap(s, p, "object.group", Wrap::Group)), spec!("object.ungroup", "Ungroup selection", "{ids?}", true, ungroup), - spec!("object.frameSelection", "Frame selection", "{ids?, name?}", true, |s, p| wrap(s, p, Wrap::Frame)), + spec!("object.frameSelection", "Frame selection", "{ids?, name?}", true, |s, p| wrap(s, p, "object.frameSelection", Wrap::Frame)), spec!("object.removeFrame", "Remove frame", "{ids?}", true, ungroup), spec!( "object.convertToGroup", @@ -42,7 +44,7 @@ pub fn specs() -> Vec { "Wrap in new section", "{ids?, name?} (layers on the page or in a section; the section wraps them with some room)", true, - |s, p| wrap(s, p, Wrap::Section) + |s, p| wrap(s, p, "object.wrapInSection", Wrap::Section) ), spec!( "object.resizeToFit", @@ -71,7 +73,9 @@ pub fn specs() -> Vec { // Hugging needs auto layout and the children that filled have nothing to fill: both // become fixed (the frame keeps filling its own auto layout parent). let doc = s.doc_mut()?; - doc.get_mut(i)?.layout = None; + let n = doc.get_mut(i)?; + n.layout = None; + super::props::drop_layout_bindings(n); figcraft_layout::coherent_sizing(doc, i); } ok() @@ -103,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| { @@ -128,26 +146,27 @@ const SECTION_PAD: f64 = 40.0; const BOOL_PARAMS: &str = "{ids?} — layers inside boolean groups: sets their own operation (how each combines with the operands below it); one boolean group: sets its operation (every operand follows it); otherwise wraps the layers in a new boolean group"; /// Wrap the selection in a new boolean group doing `op`. -pub(crate) fn wrap_boolean(s: &mut Session, p: &Value, op: BoolOp) -> Result { - wrap(s, p, Wrap::Bool(op)) +pub(crate) fn wrap_boolean(s: &mut Session, p: &Value, cmd: &str, op: BoolOp) -> Result { + wrap(s, p, cmd, Wrap::Bool(op)) } -/// Wrap the selection in a new container at the topmost selected layer's place. -fn wrap(s: &mut Session, p: &Value, w: Wrap) -> Result { - let ids = req_targets("object.group", s, p)?; +/// Wrap the selection in a new container at the topmost selected layer's place. Errors name `cmd`, +/// the command that asked. +fn wrap(s: &mut Session, p: &Value, cmd: &str, w: Wrap) -> Result { + let ids = req_targets(cmd, s, p)?; let doc = s.doc_mut()?; let ids = z_sorted(doc, &roots(doc, &ids)); - let top = *ids.last().ok_or_else(|| bad("object.group", "nothing selected"))?; - let parent = doc.get(top)?.parent.ok_or_else(|| bad("object.group", "can't group a page"))?; + let top = *ids.last().ok_or_else(|| bad(cmd, "nothing selected"))?; + let parent = doc.get(top)?.parent.ok_or_else(|| bad(cmd, "can't group a page"))?; // Sections sit on the page or in sections, never in frames or groups. let board = |doc: &Document, i: NodeId| { doc.node(i).and_then(|n| n.parent).and_then(|p| doc.node(p)).is_some_and(|p| p.is_page() || matches!(p.kind, NodeKind::Section { .. })) }; if matches!(w, Wrap::Section) && !ids.iter().all(|i| board(doc, *i)) { - return Err(bad("object.wrapInSection", "only layers on the page or in a section can be wrapped in a section")); + return Err(bad(cmd, "only layers on the page or in a section can be wrapped in a section")); } if !matches!(w, Wrap::Section) && ids.iter().any(|i| doc.node(*i).is_some_and(|n| matches!(n.kind, NodeKind::Section { .. }))) { - return Err(bad("object.group", "sections can't go in groups or frames")); + return Err(bad(cmd, "sections can't go in groups or frames")); } let index = doc.index_in_parent(top).map(|i| i + 1); let mut bounds = doc.bounds_of(&ids).unwrap_or_default(); @@ -285,7 +304,7 @@ fn convert_to_section(s: &mut Session, p: &Value) -> Result { n.grids.clear(); n.guides.clear(); n.styles.grid = None; - n.bound.retain(|k, _| !k.starts_with("padding") && !matches!(k.as_str(), "gap" | "counterAxisSpacing")); + super::props::drop_layout_bindings(n); let kids = n.children.clone(); for k in kids { // Out of the flow there is nothing to fill or to be absolute against. @@ -338,7 +357,7 @@ fn mask(s: &mut Session, p: &Value) -> Result { return ok(); } // Group, then the bottom-most child is the mask. - let r = wrap(s, &json!({"ids": ids, "name": "Mask group"}), Wrap::Group)?; + let r = wrap(s, &json!({"ids": ids, "name": "Mask group"}), "object.mask", Wrap::Group)?; let gid = id_of(&r["id"]).ok_or_else(|| EngineError::Other("group failed".into()))?; let doc = s.doc_mut()?; if let Some(first) = doc.get(gid)?.children.first().copied() { @@ -475,7 +494,7 @@ fn outline_stroke(s: &mut Session, p: &Value) -> Result { let f = doc.get_mut(fill_id)?; f.strokes.clear(); } - let r = wrap(s, &json!({"ids": [fill_id, vid], "name": n.name}), Wrap::Group)?; + let r = wrap(s, &json!({"ids": [fill_id, vid], "name": n.name}), "vector.outlineStroke", Wrap::Group)?; if let Some(g) = id_of(&r["id"]) { out.push(g); } @@ -522,7 +541,7 @@ fn layout_add(s: &mut Session, p: &Value) -> Result { ids } else { // Wrap non-frames in a new frame without a fill, then add auto layout to it. - let r = wrap(s, &json!({"ids": ids}), Wrap::Frame)?; + let r = wrap(s, &json!({"ids": ids}), "layout.add", Wrap::Frame)?; let fid = id_of(&r["id"]).ok_or_else(|| EngineError::Other("wrap failed".into()))?; let f = s.doc_mut()?.get_mut(fid)?; f.fills.clear(); @@ -727,3 +746,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 25e36e3a..dd93bac3 100644 --- a/crates/engine/src/cmd/styles.rs +++ b/crates/engine/src/cmd/styles.rs @@ -110,16 +110,26 @@ pub fn specs() -> Vec { )) }), spec!("variable.createCollection", "Create variable collection", "{name, modes? [names]}", true, |s, p| { - let name = req_string("variable.createCollection", p, "name")?; - let doc = s.doc_mut()?; - if doc.variables.collections.iter().any(|c| c.name == name) { - return Err(bad("variable.createCollection", format!("a collection named `{name}` already exists"))); + let cmd = "variable.createCollection"; + let name = req_string(cmd, p, "name")?.trim().to_string(); + if name.is_empty() { + return Err(bad(cmd, "name can't be empty")); } - let id = doc.new_collection_id(); let modes: Vec = match get(p, "modes") { - Some(Value::Array(a)) => a.iter().filter_map(|m| m.as_str().map(str::to_string)).collect(), + Some(Value::Array(a)) => a.iter().filter_map(|m| m.as_str().map(|m| m.trim().to_string())).collect(), _ => vec!["Mode 1".into()], }; + if modes.is_empty() { + return Err(bad(cmd, "a collection needs at least one mode")); + } + if modes.iter().any(String::is_empty) { + return Err(bad(cmd, "mode names can't be empty")); + } + let doc = s.doc_mut()?; + if doc.variables.collections.iter().any(|c| c.name == name) { + return Err(bad(cmd, format!("a collection named `{name}` already exists"))); + } + let id = doc.new_collection_id(); let modes = modes.into_iter().enumerate().map(|(i, m)| Mode { id: format!("{id}/mode{}", i + 1), name: m }).collect(); doc.variables.collections.push(Collection { id: id.clone(), name, modes, extends: None }); Ok(json!({"id": id})) @@ -458,10 +468,19 @@ fn bind_style(s: &mut Session, p: &Value) -> Result { match var { Some((_, kind)) if kind != want => return Err(bad(cmd, format!("`{field}` takes a {} variable, not a {} one", want.id(), kind.id()))), Some((id, _)) => { + if style.bound.get(&key) != Some(&id) + && let StyleKind::Paint { paints } = &mut style.kind + { + rebind_paint_alpha(paints, &mut [], &key, true); + } style.bound.insert(key, id); } None => { - style.bound.remove(&key); + if style.bound.remove(&key).is_some() + && let StyleKind::Paint { paints } = &mut style.kind + { + rebind_paint_alpha(paints, &mut [], &key, false); + } } } // The style takes its default-mode value, and each layer using it its own. @@ -770,16 +789,49 @@ pub fn bind(s: &mut Session, p: &Value) -> Result { let n = doc.get_mut(i)?; match &vid { Some(v) => { + if n.bound.get(&field) != Some(v) { + rebind_paint_alpha(&mut n.fills, &mut n.strokes, &field, true); + } n.bound.insert(field.clone(), v.clone()); } None => { - n.bound.remove(&field); + if n.bound.remove(&field).is_some() { + rebind_paint_alpha(&mut n.fills, &mut n.strokes, &field, false); + } } } } ok() } +/// A colour variable bound to a solid paint owns its whole colour, alpha included. Binding restarts +/// an opacity that holds the alpha a typed colour had (see `Paint::solid`) at 1, 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 }; + let paints = match list { + BoundList::Fills => fills, + BoundList::Strokes => strokes, + _ => return, + }; + if let Some(p) = paints.get_mut(i) + && let PaintKind::Solid { color } = &mut p.kind + { + if bind { + if p.color_alpha { + p.opacity = 1.0; + } + p.color_alpha = false; + } else if color.a < 1.0 { + p.opacity *= color.a; + p.color_alpha = true; + } + color.a = 1.0; + } +} + /// `variable.setMode`: choose a collection's mode on frames (the page when none are given). pub fn set_mode(s: &mut Session, p: &Value) -> Result { let cmd = "variable.setMode"; @@ -916,6 +968,147 @@ pub fn apply_bindings_to(doc: &mut Document, only: Option<&std::collections::BTr apply_bindings_matching(doc, only, |_| true); } +/// The layers `ids` before an explicit edit, for [`detach_edited`] (only those with bindings). +pub(crate) fn bound_before(doc: &Document, ids: &[NodeId]) -> Vec> { + ids.iter().filter_map(|i| doc.node_arc(*i)).filter(|n| !n.bound.is_empty()).cloned().collect() +} + +/// An explicit edit sticks: each layer's own bound field (not a list item's) whose value the edit +/// changed drops its binding, which would otherwise write the variable's value back (#809). +/// Compared by value, so keys written unchanged keep their bindings and fields moved along with +/// another (proportions, min/max) lose theirs. +pub(crate) fn detach_edited(doc: &mut Document, before: &[std::sync::Arc]) { + for old in before { + let Some(n) = doc.node(old.id) else { continue }; + let edited: Vec = n + .bound + .keys() + // A binding the edit itself made (`boundVariables`) stays. + .filter(|f| old.bound.get(*f) == n.bound.get(*f)) + .filter(|f| super::lists::list_binding(f).is_none() && !f.starts_with(crate::components::PROP_BINDING)) + .filter(|f| bound_field(old, f) != bound_field(n, f)) + .cloned() + .collect(); + if edited.is_empty() { + continue; + } + if let Ok(n) = doc.get_mut(old.id) { + for f in edited { + n.bound.remove(&f); + } + } + } +} + +/// The stored value a layer-level binding writes, comparable (`None`: the layer has no such field). +fn bound_field(n: &Node, field: &str) -> Option { + let text = |f: &dyn Fn(&TextStyle) -> String| match &n.kind { + NodeKind::Text(t) => Some(std::iter::once(&t.style).chain(t.runs.iter().map(|r| &r.style)).map(f).collect::>().join(",")), + _ => None, + }; + let layout = n.layout.as_ref(); + let cl = &n.child_layout; + let pad = |i: usize| layout.and_then(|l| l.padding.get(i).copied()); + Some(match field { + "width" => format!("{}", n.width), + "height" => format!("{}", n.height), + "minWidth" => format!("{:?}", cl.min_w), + "maxWidth" => format!("{:?}", cl.max_w), + "minHeight" => format!("{:?}", cl.min_h), + "maxHeight" => format!("{:?}", cl.max_h), + "opacity" => format!("{}", n.opacity), + "visible" => format!("{}", n.visible), + "gap" | "columnGap" => format!("{:?}", layout.map(|l| l.gap)), + "crossGap" | "rowGap" => format!("{:?}", layout.map(|l| l.cross_gap)), + "padding" => format!("{:?}", layout.map(|l| l.padding)), + "paddingTop" => format!("{:?}", pad(0)), + "paddingRight" => format!("{:?}", pad(1)), + "paddingBottom" => format!("{:?}", pad(2)), + "paddingLeft" => format!("{:?}", pad(3)), + "paddingHorizontal" => format!("{:?}", (pad(1), pad(3))), + "paddingVertical" => format!("{:?}", (pad(0), pad(2))), + "cornerRadius" => format!("{:?}", n.corners.radii), + "topLeftRadius" => format!("{:?}", n.corners.radii.first()), + "topRightRadius" => format!("{:?}", n.corners.radii.get(1)), + "bottomRightRadius" => format!("{:?}", n.corners.radii.get(2)), + "bottomLeftRadius" => format!("{:?}", n.corners.radii.get(3)), + "strokeWeight" => format!("{:?}", (n.stroke.weight, n.stroke.sides)), + "strokeTopWeight" | "strokeRightWeight" | "strokeBottomWeight" | "strokeLeftWeight" => format!("{:?}", n.stroke.sides), + "arcStart" | "arcSweep" | "arcInner" => match &n.kind { + NodeKind::Ellipse { start, sweep, inner } => format!( + "{}", + if field == "arcStart" { + start + } else if field == "arcSweep" { + sweep + } else { + inner + } + ), + _ => return None, + }, + "characters" => match &n.kind { + NodeKind::Text(t) => t.characters.clone(), + _ => return None, + }, + "paragraphSpacing" | "paragraphIndent" => match &n.kind { + NodeKind::Text(t) => format!("{}", if field == "paragraphSpacing" { t.paragraph_spacing } else { t.paragraph_indent }), + _ => return None, + }, + "fontFamily" => text(&|s| s.font_family.clone())?, + "fontStyle" => text(&|s| s.font_style.clone())?, + "fontWeight" => text(&|s| s.weight().to_string())?, + "fontSize" => text(&|s| s.font_size.to_string())?, + "lineHeight" => text(&|s| format!("{:?}", s.line_height))?, + "letterSpacing" => text(&|s| format!("{:?}", s.letter_spacing))?, + // shortcut: other layer-level fields keep their binding on edits; add a reader when one is bound in practice. + _ => return None, + }) +} + +/// Imported files (`.fig`, REST) keep a solid paint's alpha in its opacity, a bound paint's too, +/// while the bound colour brings its own alpha: take the variable's alpha back out of the opacity +/// (styles' paints and layers') before the bindings are first resolved, so it applies once. +pub(crate) fn unfold_imported_bound_alpha(doc: &mut Document) { + use super::lists::{BoundList, list_binding}; + fn alphas(vars: &Variables, bound: &BTreeMap, modes: &BTreeMap) -> Vec<(BoundList, usize, f32)> { + let found = bound.iter().filter_map(|(field, var)| match (list_binding(field)?, vars.resolve(var, modes)?) { + ((list @ (BoundList::Fills | BoundList::Strokes), i, "color"), VariableValue::Color(c)) if c.a > 0.0 && c.a < 1.0 => Some((list, i, c.a)), + _ => None, + }); + found.collect() + } + fn unfold(p: Option<&mut Paint>, a: f32) { + if let Some(p) = p + && matches!(p.kind, PaintKind::Solid { .. }) + { + p.opacity = (p.opacity / a).min(1.0); + } + } + let pending = doc.take_dirty(); + for i in 0..doc.styles.len() { + let found = doc.styles.get(i).map(|s| alphas(&doc.variables, &s.bound, &BTreeMap::new())).unwrap_or_default(); + if let Some(StyleKind::Paint { paints }) = doc.styles.get_mut(i).filter(|_| !found.is_empty()).map(|s| &mut s.kind) { + for (_, j, a) in found { + unfold(paints.get_mut(j), a); + } + } + } + let ids: Vec = doc.nodes().filter(|n| !n.bound.is_empty()).map(|n| n.id).collect(); + for id in ids { + let found = doc.node(id).map(|n| alphas(&doc.variables, &n.bound, &doc.modes_for(id))).unwrap_or_default(); + if found.is_empty() { + continue; + } + let Ok(n) = doc.get_mut(id) else { continue }; + for (list, j, a) in found { + unfold(if list == BoundList::Fills { n.fills.get_mut(j) } else { n.strokes.get_mut(j) }, a); + } + } + doc.take_dirty(); + doc.mark_dirty(pending); +} + /// Settle imported paint colours before the document is first shown. Other bindings can affect /// layout or component structure, which must be resolved through the normal edit pipeline. pub(crate) fn apply_paint_bindings_on_load(doc: &mut Document) { @@ -930,6 +1123,8 @@ pub(crate) fn apply_paint_bindings_on_load(doc: &mut Document) { doc.mark_dirty(pending); } +/// Re-apply the variable bindings whose field passes `include`, on the `only` nodes (every bound +/// node when `None`). Auto layout fields wait while their node has no auto layout. fn apply_bindings_matching(doc: &mut Document, only: Option<&std::collections::BTreeSet>, include: impl Fn(&str) -> bool) { use super::lists::{BoundList, list_binding}; if doc.variables.variables.is_empty() { @@ -944,6 +1139,7 @@ fn apply_bindings_matching(doc: &mut Document, only: Option<&std::collections::B let modes = doc.modes_for(id); let Some(n) = doc.node(id) else { continue }; let binds = n.bound.clone(); + let has_layout = n.layout.is_some(); let mut sets = serde_json::Map::new(); let mut effect_sets: Vec<(usize, String, VariableValue)> = Vec::new(); let mut paint_sets: Vec<(bool, usize, Option, Color)> = Vec::new(); @@ -953,6 +1149,10 @@ fn apply_bindings_matching(doc: &mut Document, only: Option<&std::collections::B if !include(&field) { continue; } + // A binding never creates an auto layout: without one it waits for the next. + if !has_layout && super::props::LAYOUT_KEYS.contains(&field.as_str()) { + continue; + } let Some(val) = doc.variables.resolve(&var, &modes) else { continue }; // One list item's value: a paint's colour, an effect's or a layout grid's field. if let Some((list, i, prop)) = list_binding(&field) { @@ -1104,7 +1304,6 @@ pub fn document_colors(doc: &Document, page: NodeId) -> Vec<(Color, usize)> { stack.extend(n.children.iter().copied()); for paint in n.fills.iter().chain(n.strokes.iter()).filter(|p| p.visible) { if let Some(c) = paint.solid_color() { - let c = c.with_alpha(c.a * paint.opacity); counts.entry(c.to_rgba8()).or_insert((c, 0)).1 += 1; } } diff --git a/crates/engine/src/cmd/text.rs b/crates/engine/src/cmd/text.rs index 3b861895..a2f76115 100644 --- a/crates/engine/src/cmd/text.rs +++ b/crates/engine/src/cmd/text.rs @@ -18,6 +18,7 @@ fn style(s: &mut Session, p: &Value, f: impl Fn(&mut TextStyle)) -> Result Result = ids.into_iter().flat_map(|i| doc.subtree(i)).collect(); + let before = super::styles::bound_before(doc, &ids); + for d in ids { + if let Some(NodeKind::Text(t)) = doc.node_mut(d).map(|n| &mut n.kind) { + let n = t.char_count(); + t.restyle(0, n, &f); } } + super::styles::detach_edited(doc, &before); ok() } @@ -348,11 +351,13 @@ pub fn specs() -> Vec { let c = string(p, "characters").or_else(|| string(p, "text")).ok_or_else(|| bad("text.set", "`characters` required"))?; let ids = req_targets("text.set", s, p)?; let doc = s.doc_mut()?; + let before = super::styles::bound_before(doc, &ids); for i in ids { let mut m = Map::new(); m.insert("characters".into(), json!(c)); super::props::apply("text.set", doc, i, &m)?; } + super::styles::detach_edited(doc, &before); ok() }), spec!( @@ -364,6 +369,7 @@ pub fn specs() -> Vec { let ids = req_targets("text.style", s, p)?; let m = p.as_object().cloned().unwrap_or_default(); let doc = s.doc_mut()?; + let before = super::styles::bound_before(doc, &ids); for i in ids { let n = doc.get_mut(i)?; let NodeKind::Text(t) = &mut n.kind else { return Err(bad("text.style", format!("{i} is not text"))) }; @@ -377,6 +383,7 @@ pub fn specs() -> Vec { super::props::apply_text_style("text.style", t, a, b, k, v)?; } } + super::styles::detach_edited(doc, &before); ok() } ), @@ -474,5 +481,40 @@ pub fn specs() -> Vec { }) } ), + spec!( + "text.rescanFonts", + "Rescan installed fonts", + "{} walks the system font folders again, for fonts installed while the app runs; the open document's auto-sizing text in the families that gained faces (new ones, or styles installed since) fits them (text.refit) → {added: [families that were unknown], refitted: [ids]}", + true, + |s, _| { + let rescan = figcraft_text::rescan_system_fonts(); + let refitted = crate::fonts::refit_open(s, None, Some(&rescan.changed)); + Ok(json!({"added": rescan.added, "refitted": refitted})) + } + ), + spec!( + "text.loadFont", + "Load font file", + "{path | data (base64)} adds a font file (.ttf, .otf, .ttc, .otc; every face of a collection) to this session's fonts without installing it; the open document's auto-sizing text in its families fits it (text.refit) → {families, refitted: [ids]}", + true, + |s, p| { + let cmd = "text.loadFont"; + let bytes = if let Some(b64) = string(p, "data") { + use base64::Engine as _; + let b64 = b64.split_once(',').map(|(_, d)| d.to_string()).unwrap_or(b64); + base64::engine::general_purpose::STANDARD.decode(b64.trim()).map_err(|e| bad(cmd, format!("`data`: {e}")))? + } else if let Some(path) = string(p, "path") { + super::file::read_capped(&path, figcraft_text::fontdb::MAX_FONT_FILE)? + } else { + return Err(bad(cmd, "pass `path` or `data` (base64)")); + }; + let families = figcraft_text::load_font_bytes(bytes); + if families.is_empty() { + return Err(bad(cmd, "not a font file")); + } + let refitted = crate::fonts::refit_open(s, None, Some(&families)); + Ok(json!({"families": families, "refitted": refitted})) + } + ), ] } diff --git a/crates/engine/src/cmd/variables.rs b/crates/engine/src/cmd/variables.rs index 3e6dc69d..53b62ee4 100644 --- a/crates/engine/src/cmd/variables.rs +++ b/crates/engine/src/cmd/variables.rs @@ -288,7 +288,11 @@ pub(crate) fn forget_variables(doc: &mut Document, gone: &BTreeSet) doc.variables.variables.retain(|v| !gone.contains(&v.id)); let users: Vec = doc.nodes().filter(|n| n.bound.values().any(|v| gone.contains(v))).map(|n| n.id).collect(); for id in users { - doc.get_mut(id)?.bound.retain(|_, v| !gone.contains(v)); + let n = doc.get_mut(id)?; + for (field, _) in n.bound.iter().filter(|(_, v)| gone.contains(*v)) { + super::styles::rebind_paint_alpha(&mut n.fills, &mut n.strokes, field, false); + } + n.bound.retain(|_, v| !gone.contains(v)); } Ok(()) } diff --git a/crates/engine/src/cmd/view.rs b/crates/engine/src/cmd/view.rs index 69b938e9..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())); @@ -370,7 +370,25 @@ pub fn specs() -> Vec { spec!("pref.showDimensions", "Show dimensions on objects", "{on?}", false, |s, p| pref(s, p, |x| &mut x.show_dimensions)), spec!("pref.numberKeysOpacity", "Use number keys for opacity", "{on?}", false, |s, p| pref(s, p, |x| &mut x.number_keys_opacity)), spec!("pref.reduceMotion", "Reduce motion", "{on?}", false, |s, p| pref(s, p, |x| &mut x.reduce_motion)), + spec!( + "pref.uiScale", + "Interface size", + "{scale?: a size from 0.75 to 3 relative to the system's display scale, 1 being the system's own size, so 1.25 is 125 %, not 125 (outside the range it stops at the nearest end; \"auto\" is 1; omit: just read it)} → {scale}: how large the whole interface is drawn; the canvas zoom is separate", + false, + |s, p| { + match get(p, "scale") { + None => {} + Some(Value::String(v)) if v.trim().eq_ignore_ascii_case("auto") => s.prefs.ui_scale = 1.0, + Some(_) => { + let scale = req_num("pref.uiScale", p, "scale")?; + s.prefs.ui_scale = crate::clamp_ui_scale(scale).ok_or_else(|| bad("pref.uiScale", "`scale` must be a finite number"))?; + } + } + Ok(json!({"scale": s.prefs.ui_scale})) + } + ), spec!("pref.autoSave", "Save files automatically", "{on?}", false, |s, p| pref(s, p, |x| &mut x.auto_save)), + spec!("pref.startOnHome", "Start on the Home screen", "{on?}", false, |s, p| pref(s, p, |x| &mut x.start_on_home)), spec!("pref.nudge", "Nudge amount", "{small?, big?}", false, |s, p| { s.prefs.nudge_small = num(p, "small").unwrap_or(s.prefs.nudge_small).clamp(0.01, 1000.0); s.prefs.nudge_big = num(p, "big").unwrap_or(s.prefs.nudge_big).clamp(0.01, 10000.0); @@ -409,7 +427,7 @@ pub fn specs() -> Vec { spec!( "pref.language", "Interface language", - "{language: system|en|cs|ru|es|es-419|fr|de|pt-BR|ja|ko|zh-Hans|…; else English}", + "{language: system|en|cs|ru|es|es-419|fr|de|pt-BR|ja|ka|ko|zh-Hans|…; else English}", false, |s, p| { let lang = req_string("pref.language", p, "language")?; @@ -447,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) } ), @@ -516,9 +537,37 @@ 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) + }), ] } #[allow(dead_code)] fn unused(_: NodeId) {} + +#[cfg(test)] +mod start_on_home_tests { + use serde_json::json; + + use crate::Session; + + #[test] + fn start_on_home_is_a_preference_that_persists() { + let mut s = Session::new(); + assert!(!s.prefs.start_on_home); + assert_eq!(s.execute("pref.startOnHome", &json!({})).unwrap()["on"], true); + assert!(s.prefs.start_on_home); + // Saved with the other preferences and read back; older settings files without it read as off. + let saved: crate::Prefs = serde_json::from_value(serde_json::to_value(&s.prefs).unwrap()).unwrap(); + assert!(saved.start_on_home); + assert_eq!(s.execute("pref.startOnHome", &json!({"on": false})).unwrap()["on"], false); + let old: crate::Prefs = serde_json::from_value(json!({"theme": "dark"})).unwrap(); + assert!(!old.start_on_home); + } +} 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 7d35229f..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 } @@ -256,7 +256,26 @@ fn stroke_color(n: &Node) -> Option { if !n.has_visible_stroke() { return None; } - n.strokes.iter().filter(|p| p.visible).find_map(|p| p.solid_color().map(|c| c.with_alpha(c.a * p.opacity))) + n.strokes.iter().filter(|p| p.visible).find_map(|p| p.solid_color()) +} + +/// The CSS borders a solid stroke makes: one `border`, or with individual stroke sides a border +/// on each side that has a weight. +fn css_borders(doc: &Document, n: &Node, t: &mut Tokens) -> Vec<(String, String)> { + let Some(c) = stroke_color(n) else { return Vec::new() }; + let Some(sides) = n.stroke.sides else { + let weight = tok(doc, n, &["strokeWeight"], format!("{}px", num(n.stroke.weight)), t); + return vec![("border".into(), format!("{weight} solid {}", tok(doc, n, &["stroke"], c.to_css(), t)))]; + }; + let color = tok(doc, n, &["stroke"], c.to_css(), t); + let named = ["top", "right", "bottom", "left"].into_iter().zip(crate::cmd::props::STROKE_SIDES); + named + .zip(sides) + .filter(|(_, weight)| *weight > 0.0) + .map(|((side, field), weight)| { + (format!("border-{side}"), format!("{} solid {color}", tok(doc, n, &[field], format!("{}px", num(weight)), t))) + }) + .collect() } fn drop_shadow(n: &Node) -> Option<&Shadow> { @@ -452,9 +471,8 @@ pub(crate) fn css_decls(doc: &Document, n: &Node, t: &mut Tokens, hug: (bool, bo PaintKind::Pattern(_) => put("background", "url(pattern.png) repeat".into()), } } - if let Some(c) = stroke_color(n) { - let weight = tok(doc, n, &["strokeWeight"], px(n.stroke.weight), t); - put("border", format!("{weight} solid {}", tok(doc, n, &["stroke"], c.to_css(), t))); + for (key, value) in css_borders(doc, n, t) { + put(&key, value); } let shadows: Vec = n .effects @@ -858,11 +876,16 @@ pub(crate) fn swift(ctx: &Ctx, id: NodeId, depth: usize, out: &mut String, extra swift_layer(ctx, n, depth, out, extra); } +/// A layer name safe for a single-line `//` comment: line breaks become spaces. +fn one_line(name: &str) -> String { + name.replace(['\n', '\r', '\u{85}', '\u{2028}', '\u{2029}'], " ") +} + fn swift_layer(ctx: &Ctx, n: &Node, depth: usize, out: &mut String, extra: &[String]) { let doc = ctx.doc; let pad = indent(depth); if ctx.too_deep(depth) { - out.push_str(&format!("{pad}// {} …\n", n.name)); + out.push_str(&format!("{pad}// {} …\n", one_line(&n.name))); return; } // Nested instances are calls; an instance swap parameter is a view. @@ -1035,7 +1058,7 @@ fn compose_layer(ctx: &Ctx, n: &Node, depth: usize, out: &mut String) { let doc = ctx.doc; let pad = indent(depth); if ctx.too_deep(depth) { - out.push_str(&format!("{pad}// {} …\n", n.name)); + out.push_str(&format!("{pad}// {} …\n", one_line(&n.name))); return; } // Nested instances are calls; an instance swap parameter is a composable. @@ -1168,4 +1191,26 @@ mod tests { assert!(kt.contains("Modifier.offset(7.dp, 9.dp)")); assert!(code_for(doc, NodeId(999_999), CodeLang::Css).is_empty()); } + + #[test] + fn too_deep_placeholder_keeps_a_multiline_layer_name_in_the_comment() { + let mut s = Session::new(); + let name = "deep\nevil()\r\u{2028}more()"; + let mut parent = id_of(&s.execute("frame.create", &json!({"x": 0, "y": 0, "width": 50, "height": 50, "name": name})).unwrap()["id"]).unwrap(); + let root = parent; + for _ in 0..16 { + parent = + id_of(&s.execute("frame.create", &json!({"x": 1, "y": 1, "width": 40, "height": 40, "parent": parent, "name": name})).unwrap()["id"]) + .unwrap(); + } + let doc = s.doc().unwrap(); + for lang in [CodeLang::SwiftUi, CodeLang::Compose] { + let code = code_for(doc, root, lang); + assert!(code.contains("// deep evil() more() …\n"), "{code}"); + for line in code.lines() { + let t = line.trim_start(); + assert!(!t.starts_with("evil()") && !t.starts_with("more()"), "layer name leaked as code:\n{code}"); + } + } + } } 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 56efc5c9..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])> { @@ -285,11 +304,28 @@ pub fn create_instance_at(doc: &mut Document, component: NodeId, parent: NodeId, root.dev_resources.clear(); // The main's interactions play on its instances in prototypes; the instance's own start empty. root.interactions.clear(); + // So do the variable modes the main sets (see `Document::inherited_modes`). + root.var_modes.clear(); doc.insert(root, parent, index)?; sync_instance(doc, id); Ok(id) } +/// Copy `id` within its own file under `parent` (at `index`), keeping its local transform. A file +/// has one main per component, so a main component's copy is an instance of it; a variant copied +/// into its set stays a new variant, and every other layer is deep-copied. +pub fn copy_in_file(doc: &mut Document, id: NodeId, parent: NodeId, index: Option) -> Result { + let n = doc.get(id)?; + let in_set = doc.node(parent).is_some_and(|p| matches!(p.kind, NodeKind::ComponentSet { .. })); + if !matches!(n.kind, NodeKind::Component { .. }) || in_set { + return doc.duplicate_into(id, parent, index); + } + let transform = n.transform; + let instance = create_instance_at(doc, id, parent, 0.0, 0.0, index)?; + doc.get_mut(instance)?.transform = transform; + Ok(instance) +} + /// Which part of the carried overrides to apply. #[derive(Clone, Copy, PartialEq)] enum Carry { @@ -318,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)) { @@ -398,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; @@ -762,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) { @@ -813,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; @@ -830,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)) @@ -1032,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"); @@ -1129,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)); @@ -1952,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 { @@ -1976,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 15f660e1..a95e1268 100644 --- a/crates/engine/src/components_behaviour_tests.rs +++ b/crates/engine/src/components_behaviour_tests.rs @@ -82,6 +82,179 @@ fn text(n: &Node) -> &TextData { n.kind.text().unwrap() } +#[test] +fn copying_a_main_component_pastes_a_linked_instance() { + for command in ["edit.paste", "edit.pasteOver", "edit.pasteReplace"] { + let mut s = Session::new(); + let k = kit(&mut s); + ex(&mut s, "select.set", json!({"ids": [k.card]})); + ex(&mut s, "edit.copy", json!({})); + if command != "edit.paste" { + ex(&mut s, "select.set", json!({"ids": [k.inst]})); + } + let before = undo_len(&s); + let pasted = crate::params::id_of(&ex(&mut s, command, json!({}))["ids"][0]).unwrap(); + assert_eq!(component_of(&s, pasted), k.card, "{command}"); + assert_eq!(node(&s, pasted).parent, Some(s.page().unwrap())); + assert_eq!(node(&s, pasted).source, Some(k.card)); + assert_eq!(undo_len(&s), before + 1); + ex(&mut s, "edit.undo", json!({})); + assert!(s.doc().unwrap().node(pasted).is_none()); + ex(&mut s, "edit.redo", json!({})); + assert_eq!(component_of(&s, pasted), k.card); + ex(&mut s, "node.set", json!({"id": k.title, "props": {"characters": "Updated"}})); + assert_eq!(chars(&s, copy_of(&s, pasted, k.title)), "Updated"); + healthy(&s); + } +} + +#[test] +fn copying_a_variant_pastes_an_instance_but_copying_its_set_keeps_the_components() { + let mut s = Session::new(); + let (set, _, hover) = button_set(&mut s); + ex(&mut s, "select.set", json!({"ids": [hover]})); + ex(&mut s, "edit.copy", json!({})); + let pasted = crate::params::id_of(&ex(&mut s, "edit.paste", json!({}))["ids"][0]).unwrap(); + assert_eq!(component_of(&s, pasted), hover); + assert_eq!(node(&s, pasted).name, node(&s, set).name); + let NodeKind::Instance { props, .. } = &node(&s, pasted).kind else { panic!("pasted variant is not an instance") }; + assert_eq!(chars(&s, layer(&s, pasted, "Label").unwrap()), "Hover"); + assert_eq!(props["Show dot"], true); + assert_eq!(node(&s, pasted).parent, Some(s.page().unwrap())); + + ex(&mut s, "select.set", json!({"ids": [set]})); + ex(&mut s, "edit.copy", json!({})); + let copied_set = crate::params::id_of(&ex(&mut s, "edit.paste", json!({}))["ids"][0]).unwrap(); + assert!(matches!(node(&s, copied_set).kind, NodeKind::ComponentSet { .. })); + assert_eq!(node(&s, copied_set).parent, Some(s.page().unwrap())); + assert_eq!(kids(&s, copied_set).len(), 2); + for variant in kids(&s, copied_set) { + assert!(matches!(node(&s, variant).kind, NodeKind::Component { .. })); + assert!(!kids(&s, set).contains(&variant)); + } + healthy(&s); +} + +#[test] +fn cut_components_and_components_copied_to_another_file_remain_components() { + for (copy, undo_cut, new_file) in [("edit.cut", false, false), ("edit.cut", true, false), ("edit.copy", false, true)] { + let mut s = Session::new(); + let k = kit(&mut s); + ex(&mut s, copy, json!({"ids": [k.card]})); + if undo_cut { + ex(&mut s, "edit.undo", json!({})); + } + if new_file { + ex(&mut s, "file.new", json!({})); + } + let page = s.page().unwrap(); + let pasted = crate::params::id_of(&ex(&mut s, "edit.paste", json!({"parent": page}))["ids"][0]).unwrap(); + assert!(matches!(node(&s, pasted).kind, NodeKind::Component { .. }), "{copy}, undo={undo_cut}, new_file={new_file}"); + assert_eq!(kids(&s, pasted).len(), 3); + healthy(&s); + } +} + +#[test] +fn copying_a_component_does_not_link_to_an_id_reused_after_undo() { + let mut s = Session::new(); + let frame = idv(&ex(&mut s, "frame.create", json!({"width": 120, "height": 60, "name": "Copied main"}))); + let main = idv(&ex(&mut s, "component.create", json!({"ids": [frame]}))); + ex(&mut s, "edit.copy", json!({"ids": [main]})); + s.undo().unwrap(); + s.undo().unwrap(); + let other = idv(&ex(&mut s, "frame.create", json!({"width": 300, "height": 200, "name": "Unrelated main"}))); + ex(&mut s, "component.create", json!({"ids": [other]})); + assert_eq!(other, main, "undo makes the original id available again"); + let page = s.page().unwrap(); + let pasted = crate::params::id_of(&ex(&mut s, "edit.paste", json!({"parent": page}))["ids"][0]).unwrap(); + assert_eq!(node(&s, pasted).name, "Copied main"); + assert_eq!((node(&s, pasted).width, node(&s, pasted).height), (120.0, 60.0)); + assert!(matches!(node(&s, pasted).kind, NodeKind::Component { .. })); + healthy(&s); +} + +#[test] +fn pasted_component_instances_keep_the_copied_transform_in_the_target_frame() { + let mut s = Session::new(); + let k = kit(&mut s); + ex(&mut s, "node.set", json!({"id": k.card, "props": {"rotation": 30}})); + let original = s.doc().unwrap().world_transform(k.card); + let frame = idv(&ex(&mut s, "frame.create", json!({"x": -100, "y": -100, "width": 1000, "height": 1000}))); + ex(&mut s, "edit.copy", json!({"ids": [k.card]})); + let pasted = crate::params::id_of(&ex(&mut s, "edit.paste", json!({"parent": frame}))["ids"][0]).unwrap(); + assert_eq!(component_of(&s, pasted), k.card); + for (actual, expected) in s.doc().unwrap().world_transform(pasted).as_coeffs().into_iter().zip(original.as_coeffs()) { + assert!((actual - expected).abs() < 1e-9); + } + assert_eq!(node(&s, pasted).parent, Some(frame)); + let before = s.doc().unwrap().clone(); + assert!(s.execute("edit.paste", &json!({"parent": k.card})).is_err()); + assert_eq!(s.doc().unwrap(), &before, "pasting into the main is refused atomically"); + healthy(&s); +} + +#[test] +fn duplicating_a_main_component_makes_a_linked_instance() { + for rename in [false, true] { + let mut s = Session::new(); + ex(&mut s, "pref.renameDuplicates", json!({"on": rename})); + let k = kit(&mut s); + let rect = idv(&ex(&mut s, "shape.rectangle", json!({"x": 0, "y": 300, "width": 50, "height": 50, "props": {"name": "Box"}}))); + let before = s.doc().unwrap().clone(); + let out = ex(&mut s, "edit.duplicate", json!({"ids": [k.card, rect]})); + let ids: Vec = out["ids"].as_array().unwrap().iter().filter_map(crate::params::id_of).collect(); + assert_eq!(s.selection(), ids); + let copy = *ids.iter().find(|i| matches!(node(&s, **i).kind, NodeKind::Instance { .. })).expect("the main's copy is an instance"); + assert_eq!(component_of(&s, copy), k.card); + assert_eq!(node(&s, copy).name, "Card", "instances keep their default name (rename={rename})"); + assert_eq!(node(&s, copy).parent, Some(s.page().unwrap())); + let (b, c) = (s.doc().unwrap().world_bounds(k.card).unwrap(), s.doc().unwrap().world_bounds(copy).unwrap()); + assert_eq!((c.x0, c.y0, c.width(), c.height()), (b.x1 + 100.0, b.y0, b.width(), b.height()), "a top-level frame's offset"); + let other = *ids.iter().find(|i| **i != copy).unwrap(); + assert!(matches!(node(&s, other).kind, NodeKind::Rectangle), "other layers are still copied"); + ex(&mut s, "node.set", json!({"id": k.title, "props": {"characters": "Updated"}})); + assert_eq!(chars(&s, copy_of(&s, copy, k.title)), "Updated", "the instance follows its main"); + ex(&mut s, "edit.undo", json!({})); + ex(&mut s, "edit.undo", json!({})); + assert_eq!(*s.doc().unwrap(), before, "undo restores exactly"); + healthy(&s); + } +} + +#[test] +fn alt_dragging_a_main_component_copies_an_instance() { + use crate::tools::{PointerEvent, PointerKind}; + let mut s = Session::new(); + let f = idv(&ex(&mut s, "frame.create", json!({"x": 0, "y": 0, "width": 100, "height": 100, "name": "Tile"}))); + let main = idv(&ex(&mut s, "component.create", json!({"ids": [f]}))); + let ev = |kind, x| PointerEvent { alt: true, ..PointerEvent::at(kind, x, 50.0, 1.0) }; + s.pointer(&ev(PointerKind::Down, 50.0)).unwrap(); + s.pointer(&ev(PointerKind::Move, 150.0)).unwrap(); + s.pointer(&ev(PointerKind::Move, 250.0)).unwrap(); + s.pointer(&ev(PointerKind::Up, 250.0)).unwrap(); + let [copy] = s.selection()[..] else { panic!("one copy selected") }; + assert_eq!(component_of(&s, copy), main); + assert_eq!(s.doc().unwrap().world_bounds(copy).unwrap().x0, 200.0); + assert_eq!(s.doc().unwrap().world_bounds(main).unwrap().x0, 0.0, "the main stays"); + healthy(&s); +} + +#[test] +fn duplicating_a_variant_adds_a_variant_to_its_set() { + let mut s = Session::new(); + let (set, _, hover) = button_set(&mut s); + let copy = crate::params::id_of(&ex(&mut s, "edit.duplicate", json!({"ids": [hover]}))["ids"][0]).unwrap(); + assert!(matches!(node(&s, copy).kind, NodeKind::Component { .. })); + assert_eq!(node(&s, copy).parent, Some(set)); + assert_eq!(kids(&s, set).len(), 3); + // A whole set duplicates as a new set of variants. + let set2 = crate::params::id_of(&ex(&mut s, "edit.duplicate", json!({"ids": [set]}))["ids"][0]).unwrap(); + assert!(matches!(node(&s, set2).kind, NodeKind::ComponentSet { .. })); + assert!(kids(&s, set2).iter().all(|v| matches!(node(&s, *v).kind, NodeKind::Component { .. }))); + healthy(&s); +} + #[test] fn overridable_properties_stay_through_main_edits_and_reset() { // (what, layer, group, override on the instance, the same property edited on the main, read, @@ -484,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(); @@ -706,3 +937,35 @@ fn auto_layout_instances_follow_their_main() { assert_eq!(node(&s, i).child_layout.min_w, Some(80.0)); healthy(&s); } + +#[test] +fn a_new_component_set_is_named_after_its_first_variant() { + // "Button/State=Hover" groups under "Button": the set takes the path before the last '/'. + // A bare property name ("State=Default, Size=Small") names no set, and neither does an empty + // name: the set gets the next numbered default, like a new component. A plain name is kept. + let rows = [ + ("Button/State=Hover", "Button", "slash-prefixed: the path before the last '/'"), + ("Icons/Arrow/Dir=Up", "Icons/Arrow", "nested path: everything before the last '/'"), + ("State=Default, Size=Small", "Component 1", "bare prop=value: not \"State\""), + ("", "Component 1", "empty name: the numbered default"), + ]; + for (name, expected, why) in rows { + let mut s = Session::new(); + let f = idv(&ex(&mut s, "frame.create", json!({"x": 0, "y": 0, "width": 40, "height": 20}))); + ex(&mut s, "node.set", json!({"id": f, "props": {"name": name}})); + let c = idv(&ex(&mut s, "component.create", json!({"ids": [f]}))); + assert_eq!(node(&s, c).name, name, "{why}: the variant keeps its name"); + let set = idv(&ex(&mut s, "component.combineVariants", json!({"ids": [c]}))); + assert_eq!(node(&s, set).name, expected, "{why}"); + healthy(&s); + } + + // Add variant on a lone component (a variant property on it) wraps it in a set named after it. + let mut s = Session::new(); + let f = idv(&ex(&mut s, "frame.create", json!({"x": 0, "y": 0, "width": 40, "height": 20, "name": "Button"}))); + let c = idv(&ex(&mut s, "component.create", json!({"ids": [f]}))); + let set = idv(&ex(&mut s, "component.addProperty", json!({"id": c, "name": "State", "type": "variant"}))); + assert_eq!(node(&s, set).name, "Button"); + assert_eq!(node(&s, c).name, "State=Default"); + healthy(&s); +} 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/components_scale_tests.rs b/crates/engine/src/components_scale_tests.rs index c796dad8..68465932 100644 --- a/crates/engine/src/components_scale_tests.rs +++ b/crates/engine/src/components_scale_tests.rs @@ -747,3 +747,29 @@ fn alt_dropping_a_library_component_swaps_to_it() { assert!(doc.node(glyph).unwrap().overrides.is_empty()); let _ = std::fs::remove_dir_all(dir); } + +#[test] +fn a_fill_set_on_a_bound_instance_layer_detaches_it_as_an_override() { + // Without unbinding first: the explicit colour detaches the variable on the instance layer only, + // and stays through main edits until the override is reset. + let mut s = Session::new(); + let (chip, _) = bound_chip(&mut s); + let i = crate::params::id_of(&ex(&mut s, "instance.create", json!({"component": chip, "x": 200, "y": 0}))["id"]).unwrap(); + let bg = find_path(s.doc().unwrap(), i, &["Bg"]).unwrap(); + ex(&mut s, "node.set", json!({"ids": [bg], "props": {"fill": "#ff00ff"}})); + let n = s.doc().unwrap().node(bg).unwrap().clone(); + assert_eq!(n.fills[0].solid_color().unwrap().to_hex(), "FF00FF"); + assert!(!n.bound.contains_key("fill")); + assert!(n.overrides.contains("fills")); + let main_bg = find_path(s.doc().unwrap(), chip, &["Bg"]).unwrap(); + ex(&mut s, "node.set", json!({"ids": [main_bg], "props": {"height": 28}})); + ex(&mut s, "variable.set", json!({"variable": "Chip", "value": "#445566"})); + let doc = s.doc().unwrap(); + healthy(doc); + assert_eq!(doc.node(bg).unwrap().fills[0].solid_color().unwrap().to_hex(), "FF00FF"); + assert!(doc.node(main_bg).unwrap().bound.contains_key("fill"), "the main keeps its binding"); + ex(&mut s, "component.resetOverride", json!({"id": bg, "group": "fills"})); + let n = s.doc().unwrap().node(bg).unwrap(); + assert!(n.bound.contains_key("fill")); + assert_eq!(n.fills[0].solid_color().unwrap().to_hex(), "445566"); +} diff --git a/crates/engine/src/convert_container_tests.rs b/crates/engine/src/convert_container_tests.rs index 620388d2..be338a82 100644 --- a/crates/engine/src/convert_container_tests.rs +++ b/crates/engine/src/convert_container_tests.rs @@ -194,3 +194,26 @@ fn only_frames_on_the_page_or_in_a_section_become_sections() { assert!(err.contains("holds sections"), "{err}"); assert!(matches!(kind(&s, sec), NodeKind::Section { .. }), "nothing changed"); } + +#[test] +fn wrapping_errors_name_the_command_that_was_run() { + // Grouping, framing, auto layout, masks and booleans share one wrapping step; a refusal names + // the command that asked, not `object.group`. + let mut s = Session::new(); + let page = s.page().unwrap(); + for cmd in ["object.group", "object.frameSelection", "layout.add", "bool.union"] { + let err = s.execute(cmd, &json!({"ids": [page]})).unwrap_err().to_string(); + assert!(err.contains(&format!("`{cmd}`")), "{cmd}: {err}"); + } + let r = rect(&mut s, page, 0.0, 0.0); + let err = s.execute("object.mask", &json!({"ids": [page, r]})).unwrap_err().to_string(); + assert!(err.contains("`object.mask`"), "{err}"); + let section = id(ex(&mut s, "node.create", json!({"type": "section", "x": 300, "y": 0, "width": 100, "height": 100}))); + ex(&mut s, "node.set", json!({"ids": [section], "props": {"fills": ["#FFFFFF"], "strokes": ["#000000"], "strokeWeight": 1}})); + let err = s.execute("vector.outlineStroke", &json!({"ids": [section]})).unwrap_err().to_string(); + assert!(err.contains("`vector.outlineStroke`"), "{err}"); + let f = id(ex(&mut s, "frame.create", json!({"x": 100, "y": 0, "width": 100, "height": 100}))); + let inner = rect(&mut s, f, 10.0, 10.0); + let err = s.execute("object.wrapInSection", &json!({"ids": [inner, r]})).unwrap_err().to_string(); + assert!(err.contains("`object.wrapInSection`"), "{err}"); +} 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/effects_tests.rs b/crates/engine/src/effects_tests.rs index be69fac2..607b7711 100644 --- a/crates/engine/src/effects_tests.rs +++ b/crates/engine/src/effects_tests.rs @@ -51,6 +51,32 @@ fn effect_set_edits_noise_texture_and_glass() { assert!(s.execute("effect.set", &json!({"ids": [r], "index": 0, "effect": {"type": "glass", "depth": "deep"}})).is_err()); } +/// A key an effect doesn't have is refused, naming what it takes: serde dropped it without a word, +/// so a shadow given `offsetX`/`offsetY` (the names variable bindings use) kept the default offset. +#[test] +fn effects_refuse_keys_they_dont_have() { + let mut s = Session::new(); + let r = rect(&mut s); + let shadow = json!({"type": "dropShadow", "color": "#00000040", "offsetX": 0, "offsetY": 1, "radius": 3}); + let err = s.execute("node.set", &json!({"ids": [r], "props": {"effects": [shadow]}})).unwrap_err().to_string(); + assert!(err.contains("effects[0]") && err.contains("offsetX, offsetY") && err.contains("offset"), "{err}"); + assert!(s.doc().unwrap().node(r).unwrap().effects.is_empty(), "nothing stored"); + let err = s.execute("effect.add", &json!({"ids": [r], "effect": {"type": "layerBlur", "radious": 8}})).unwrap_err().to_string(); + assert!(err.contains("radious"), "{err}"); + s.execute("effect.add", &json!({"ids": [r], "type": "dropShadow"})).unwrap(); + let err = s.execute("effect.set", &json!({"ids": [r], "index": 0, "effect": {"type": "dropShadow", "spred": 2}})).unwrap_err().to_string(); + assert!(err.contains("spred"), "{err}"); + // Every kind's own keys still go in, camelCase or snake_case, and so does what inspect reports. + for ty in ["innerShadow", "layerBlur", "backgroundBlur", "noise", "texture", "glass"] { + s.execute("effect.add", &json!({"ids": [r], "type": ty})).unwrap(); + } + let reported = crate::inspect::node_json(s.doc().unwrap(), r, 0)["effects"].clone(); + s.execute("node.set", &json!({"ids": [r], "props": {"effects": reported}})).unwrap(); + let camel = json!({"type": "dropShadow", "offset": [0, 1], "showBehind": true, "visible": true}); + let snake = json!({"type": "innerShadow", "offset": [0, 1], "show_behind": false}); + s.execute("node.set", &json!({"ids": [r], "props": {"effects": [camel, snake]}})).unwrap(); +} + #[test] fn scaling_scales_grain_and_glass() { let mut s = Session::new(); diff --git a/crates/engine/src/file_access.rs b/crates/engine/src/file_access.rs index 8abeaee5..709b34a7 100644 --- a/crates/engine/src/file_access.rs +++ b/crates/engine/src/file_access.rs @@ -62,7 +62,7 @@ impl Roots { Ok(resolved) if roots.iter().any(|r| resolved.starts_with(r)) => return Ok(resolved), Ok(_) => {} // Why a path can't be resolved says what exists there: only told for paths inside a root. - Err(why) if lexically_inside(path, &roots) => return Err(format!("automation path rejected: {asked}: {why}")), + Err(why) if inside_as_far_as_it_exists(path, &roots) => return Err(format!("automation path rejected: {asked}: {why}")), Err(_) => {} } if roots.is_empty() { @@ -79,9 +79,10 @@ impl Roots { } } -/// Whether `path`, made absolute with its `.` and `..` taken by name (no file system), lies in one -/// of `roots` (as resolved, or as shown without Windows' `\\?\` prefix). -fn lexically_inside(path: &Path, roots: &[PathBuf]) -> bool { +/// Whether `path` lies in one of `roots` once made absolute with its `.` and `..` taken by name and +/// its longest leading part that exists resolved, so links on the way are followed as the roots' +/// were (`/tmp/work/x` is inside root `/private/tmp/work`, a link out of a root leads out of it). +fn inside_as_far_as_it_exists(path: &Path, roots: &[PathBuf]) -> bool { let Ok(abs) = std::path::absolute(path) else { return false }; let mut lexical = PathBuf::new(); for c in abs.components() { @@ -93,7 +94,18 @@ fn lexically_inside(path: &Path, roots: &[PathBuf]) -> bool { c => lexical.push(c), } } - roots.iter().any(|r| lexical.starts_with(r) || lexical.starts_with(shown(r))) + let mut rest: Vec<&OsStr> = Vec::new(); + let mut at = lexical.as_path(); + let resolved = loop { + if let Ok(mut base) = std::fs::canonicalize(at) { + base.extend(rest.iter().rev()); + break base; + } + let (Some(name), Some(parent)) = (at.file_name(), at.parent()) else { return false }; + rest.push(name); + at = parent; + }; + roots.iter().any(|r| resolved.starts_with(r)) } /// Whether `path` lies in the app's own folder `folder`, both resolved (links and `..` followed); diff --git a/crates/engine/src/file_access_tests.rs b/crates/engine/src/file_access_tests.rs index a05ad88c..ba55e134 100644 --- a/crates/engine/src/file_access_tests.rs +++ b/crates/engine/src/file_access_tests.rs @@ -348,6 +348,10 @@ fn outside_paths_are_refused_alike_whatever_exists_there() { let mut outside = vec![f.p("outside/secret.txt"), f.p("outside/none.txt"), f.p("outside/missing/../x.txt"), f.p("nowhere/deeper/x.txt")]; if symlink_file(&f.0.join("outside/not-yet.txt"), &f.0.join("outside/dangling.txt")) { outside.push(f.p("outside/dangling.txt")); + // Reached through a link in the root, it is outside all the same. + if symlink_dir(&f.0.join("outside"), &f.0.join("root/escape")) { + outside.push(f.p("root/escape/dangling.txt")); + } } for p in &outside { let e = refusal(p); @@ -359,6 +363,19 @@ fn outside_paths_are_refused_alike_whatever_exists_there() { } } +#[test] +fn a_root_given_through_a_link_tells_why_paths_in_it_are_refused() { + let f = Fixture::new("linked-root"); + if !symlink_dir(&f.0.join("root"), &f.0.join("via")) || !symlink_file(&f.0.join("outside/not-yet.txt"), &f.0.join("root/dangling.txt")) { + eprintln!("skipped: this system doesn't let tests make symbolic links"); + return; + } + // Like `/tmp/work` on macOS, where `/tmp` leads to `/private/tmp`. + let r = Roots::new(&[], &[f.p("via")]).unwrap(); + let e = r.check(Path::new(&f.p("via/dangling.txt")), Access::Write).unwrap_err(); + assert!(e.contains("leads nowhere"), "{e}"); +} + #[test] fn only_resolved_paths_count_as_the_apps_own_folder() { let f = Fixture::new("own"); 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/fonts.rs b/crates/engine/src/fonts.rs index 3548124d..672d4f2c 100644 --- a/crates/engine/src/fonts.rs +++ b/crates/engine/src/fonts.rs @@ -1,13 +1,15 @@ //! Fonts a document names that this machine doesn't have (#752): text layers, their styled //! ranges, text styles and the string variables bound to a font family. Text in such a family -//! is drawn in a fallback font, so the user is told which ones are missing. +//! is drawn in a fallback font, so the user is told which ones are missing. Once such a font is +//! there, [`refit`] lets auto-sizing text measure its box in it again. -use std::collections::BTreeMap; +use std::collections::{BTreeMap, BTreeSet}; -use figcraft_doc::{Document, NodeKind, StyleKind, VariableValue, Variables}; +use figcraft_doc::{Document, NodeId, NodeKind, StyleKind, VariableValue, Variables}; use serde_json::{Value, json}; use crate::cmd::CommandSpec; +use crate::params::{get, req_targets}; use crate::{Result, Session, spec}; /// A family the document names that no installed or built-in font provides. @@ -79,20 +81,78 @@ fn string_values(vars: &Variables, id: &str) -> Vec { out } +/// Auto-sizing text layers measure their boxes again: a box measured while its family was missing +/// (in a fallback font) follows the real face once that's installed or loaded. Only layers under +/// `under` (every layer: `None`) that use one of `families` (any family: `None`). Returns the +/// layers whose size changed. +pub fn refit(doc: &mut Document, under: Option<&[NodeId]>, families: Option<&[String]>) -> Vec { + let want: Option> = families.map(|f| f.iter().map(|x| x.trim().to_lowercase()).collect()); + let scope: Option> = under.map(|ids| ids.iter().flat_map(|i| doc.subtree(*i)).collect()); + let ids: Vec = doc + .nodes() + .filter(|n| scope.as_ref().is_none_or(|s| s.contains(&n.id))) + .filter(|n| { + let NodeKind::Text(t) = &n.kind else { return false }; + let uses = |w: &BTreeSet| { + w.contains(&t.style.font_family.trim().to_lowercase()) + || t.spans().iter().any(|(_, _, st)| w.contains(&st.font_family.trim().to_lowercase())) + }; + want.as_ref().is_none_or(uses) + && figcraft_text::auto_size(n).is_some_and(|(w, h)| (w - n.width).abs() > 1e-6 || (h - n.height).abs() > 1e-6) + }) + .map(|n| n.id) + .collect(); + if !ids.is_empty() { + let before = doc.clone(); + doc.mark_dirty(ids.iter().copied()); + crate::normalize(doc, &before); + } + ids +} + +/// [`refit`] in the open document, if there is one. +pub(crate) fn refit_open(s: &mut Session, under: Option<&[NodeId]>, families: Option<&[String]>) -> Vec { + match s.doc_mut() { + Ok(doc) => refit(doc, under, families), + Err(_) => Vec::new(), + } +} + +/// `text.refit`: the layers `ids` names (an `ids` that isn't a list of layers is refused), else the +/// whole document. +fn refit_text(s: &mut Session, p: &Value) -> Result { + let under = match get(p, "ids") { + None | Some(Value::Null) => None, + Some(_) => Some(req_targets("text.refit", s, p)?), + }; + s.doc()?; + Ok(json!({"refitted": refit_open(s, under.as_deref(), None)})) +} + fn missing_fonts(s: &mut Session, _: &Value) -> Result { let families: Vec = missing(s.doc()?).into_iter().map(|m| json!({"family": m.family, "layers": m.layers})).collect(); s.request_ui("dialog.missingFonts", json!({})); Ok(json!({"families": families})) } +/// The font commands: `text.missingFonts` and `text.refit`. pub fn specs() -> Vec { - vec![spec!( - "text.missingFonts", - "Missing fonts", - "{} fonts the open document names that aren't installed (text layers, styled ranges, text styles, font family variables); the app shows them → {families: [{family, layers}]}", - false, - missing_fonts - )] + vec![ + spec!( + "text.missingFonts", + "Missing fonts", + "{} fonts the open document names that aren't installed (text layers, styled ranges, text styles, font family variables); the app shows them → {families: [{family, layers}]}", + false, + missing_fonts + ), + spec!( + "text.refit", + "Fit text to fonts", + "{ids? (these layers and the text in them; default: the whole document)} auto-sizing text measures its box again, e.g. a box measured in a fallback font while its family was missing → {refitted: [ids whose size changed]}", + true, + refit_text + ), + ] } #[cfg(test)] diff --git a/crates/engine/src/fonts_tests.rs b/crates/engine/src/fonts_tests.rs index a6e2d213..0fe6dcb3 100644 --- a/crates/engine/src/fonts_tests.rs +++ b/crates/engine/src/fonts_tests.rs @@ -124,3 +124,92 @@ fn needs_an_open_document() { let mut s = Session::empty(); assert!(s.execute("text.missingFonts", &json!({})).is_err()); } + +/// Make `id`'s box stale, as if it was measured in another font: `dw` wider than its content. +fn stale(s: &mut Session, id: figcraft_doc::NodeId, dw: f64) -> f64 { + let doc = s.doc_mut().unwrap(); + let n = doc.get_mut(id).unwrap(); + let want = figcraft_text::auto_size(n).unwrap().0; + n.width = want + dw; + doc.take_dirty(); + want +} + +/// The width of layer `id`. +fn width(s: &Session, id: figcraft_doc::NodeId) -> f64 { + s.doc().unwrap().node(id).unwrap().width +} + +/// The ids a refit reports. +fn refitted(r: &Value) -> Vec { + r["refitted"].as_array().unwrap().iter().map(|v| v.as_u64().unwrap()).collect() +} + +/// Stale boxes fit their text again, by family, by layer or everywhere; fixed boxes keep theirs. +#[test] +fn refit_measures_stale_text_boxes_again() { + let mut s = Session::new(); + let a = text(&mut s, "FigCraft Refit A"); + let b = text(&mut s, "FigCraft Refit B"); + let fixed = text(&mut s, "FigCraft Refit A"); + if let NodeKind::Text(t) = &mut s.doc_mut().unwrap().get_mut(fixed).unwrap().kind { + t.auto_resize = figcraft_doc::TextAutoResize::None; + } + s.execute("select.none", &json!({})).unwrap(); + let want_a = stale(&mut s, a, -30.0); + let want_b = stale(&mut s, b, 25.0); + s.doc_mut().unwrap().get_mut(fixed).unwrap().width = 7.0; + s.doc_mut().unwrap().take_dirty(); + // Only the families asked for. + let only_a = super::refit(s.doc_mut().unwrap(), None, Some(&["figcraft refit a".to_string()])); + assert_eq!(only_a, vec![a]); + assert_eq!(width(&s, a), want_a); + assert_eq!(width(&s, b), want_b + 25.0); + // Only the layers asked for, then everything; a fixed box keeps its size. + stale(&mut s, a, 12.0); + assert!(refitted(&s.execute("text.refit", &json!({"ids": [b]})).unwrap()) == vec![b.0]); + assert_eq!((width(&s, a), width(&s, b)), (want_a + 12.0, want_b)); + assert!(refitted(&s.execute("text.refit", &json!({})).unwrap()) == vec![a.0]); + assert_eq!(width(&s, a), want_a); + assert_eq!(width(&s, fixed), 7.0); + // Nothing stale: nothing changes. + assert!(refitted(&s.execute("text.refit", &json!({})).unwrap()).is_empty()); + assert!(s.execute("text.refit", &json!({"ids": ["nope"]})).is_err()); + // An `ids` that isn't a list is refused, not taken as the whole document. + stale(&mut s, a, 9.0); + assert!(s.execute("text.refit", &json!({"ids": a.0.to_string()})).is_err()); + assert_eq!(width(&s, a), want_a + 9.0); + assert!(Session::empty().execute("text.refit", &json!({})).is_err()); +} + +/// Text inside an instance is refitted too. +#[test] +fn refit_reaches_text_in_instances() { + let mut s = Session::new(); + let t = text(&mut s, "FigCraft Refit C"); + let comp = id_of(&s.execute("component.create", &json!({"ids": [t]})).unwrap()["id"]).unwrap(); + let inst = id_of(&s.execute("instance.create", &json!({"component": comp, "x": 300, "y": 0})).unwrap()["id"]).unwrap(); + let copy = s.doc().unwrap().node(inst).unwrap().children[0]; + let want = stale(&mut s, copy, -20.0); + let r = s.execute("text.refit", &json!({"ids": [inst]})).unwrap(); + assert_eq!(refitted(&r), vec![copy.0]); + assert_eq!(width(&s, copy), want); +} + +/// `text.loadFont` refuses what isn't a font file; `text.rescanFonts` reports what it added. +#[test] +fn fonts_load_from_files_and_rescans_report_new_families() { + let mut s = Session::new(); + assert!(s.execute("text.loadFont", &json!({})).is_err()); + assert!(s.execute("text.loadFont", &json!({"data": "bm90IGEgZm9udA=="})).is_err(), "not a font file"); + assert!(s.execute("text.loadFont", &json!({"data": "%%%"})).is_err()); + assert!(s.execute("text.loadFont", &json!({"path": "no-such-dir/x.ttf"})).is_err()); + if let Some(f) = figcraft_text::CRAFT_FONTS.first() { + use base64::Engine as _; + let data = base64::engine::general_purpose::STANDARD.encode(f.bytes); + let r = s.execute("text.loadFont", &json!({"data": data})).unwrap(); + assert_eq!(r["families"].as_array().map(Vec::len), Some(1)); + } + let r = s.execute("text.rescanFonts", &json!({})).unwrap(); + assert!(r["added"].is_array()); +} 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/inspect.rs b/crates/engine/src/inspect.rs index 88bc015a..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)); } @@ -165,6 +168,10 @@ pub fn node_json(doc: &Document, id: NodeId, depth: usize) -> Value { o.insert("operandOperation".into(), json!(op)); } } + // Layer data written with `node.set {props: {pluginData}}` (pages too). + if !n.plugin_data.is_empty() { + o.insert("pluginData".into(), json!(n.plugin_data)); + } match &n.kind { NodeKind::Text(t) => { o.insert("characters".into(), json!(t.characters)); @@ -308,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/inspect_behaviour_tests.rs b/crates/engine/src/inspect_behaviour_tests.rs index a52cac86..c4bcc6be 100644 --- a/crates/engine/src/inspect_behaviour_tests.rs +++ b/crates/engine/src/inspect_behaviour_tests.rs @@ -214,6 +214,26 @@ fn css_for_common_layers() { } } +/// Individual stroke sides are borders on those sides only: a header with a divider under it isn't +/// boxed in, and a side bound to a variable names it. +#[test] +fn css_borders_follow_individual_stroke_sides() { + let mut s = Session::new(); + let r = rect(&mut s, 0.0, 0.0, 100.0, 50.0, None); + set(&mut s, &[r], json!({"strokes": ["#000000"], "strokeWeight": 1, "strokeSides": [0, 0, 2, 0]})); + let c = css(&mut s, r); + assert!(c.contains("border-bottom: 2px solid #000000;"), "{c}"); + assert!(!c.contains("border:") && !c.contains("border-top") && !c.contains("border-right") && !c.contains("border-left"), "{c}"); + + let col = ex(&mut s, "variable.createCollection", json!({"name": "Borders"})); + let col = col["id"].as_str().unwrap().to_string(); + ex(&mut s, "variable.create", json!({"collection": col, "name": "border/top", "type": "number", "value": 3})); + ex(&mut s, "variable.bind", json!({"ids": [r], "field": "strokeTopWeight", "variable": "border/top"})); + let c = css(&mut s, r); + assert!(c.contains("border-top: var(--border-top, 3px) solid #000000;"), "{c}"); + assert!(c.contains("border-bottom: 2px solid #000000;"), "{c}"); +} + #[test] fn native_code_for_a_simple_layer() { let mut s = Session::new(); diff --git a/crates/engine/src/instance_modes_tests.rs b/crates/engine/src/instance_modes_tests.rs new file mode 100644 index 00000000..43de3ca4 --- /dev/null +++ b/crates/engine/src/instance_modes_tests.rs @@ -0,0 +1,196 @@ +//! Variable modes on instances: an instance is on the modes its main component sets, under the +//! ones set on the instance itself, so switching variants or changing the main's mode reaches it. + +use serde_json::{Value, json}; + +use crate::Session; +use crate::params::id_of; +use figcraft_doc::NodeId; + +fn run(s: &mut Session, cmd: &str, p: Value) -> Value { + s.execute(cmd, &p).unwrap_or_else(|e| panic!("{cmd} {p}: {e}")) +} + +fn id(v: Value) -> NodeId { + id_of(&v["id"]).unwrap() +} + +fn fill(s: &Session, n: NodeId) -> String { + s.doc().unwrap().node(n).unwrap().fills.first().and_then(|p| p.solid_color()).map(|c| c.to_hex()).unwrap_or_default() +} + +fn set_mode(s: &mut Session, n: NodeId, mode: Value) { + run(s, "variable.setMode", json!({"ids": [n], "collection": "Tone", "mode": mode})); +} + +/// A "Tone" collection whose variable `c` is red in mode A and blue in mode B. +fn tone(s: &mut Session) { + run(s, "variable.createCollection", json!({"name": "Tone", "modes": ["A", "B"]})); + run(s, "variable.create", json!({"collection": "Tone", "name": "c", "type": "color", "values": {"A": "#FF0000", "B": "#0000FF"}})); +} + +/// A component filled from `c`, on `mode` when one is given. +fn swatch(s: &mut Session, name: &str, mode: Option<&str>) -> NodeId { + let f = id(run(s, "frame.create", json!({"x": 0, "y": 0, "width": 40, "height": 40, "name": name}))); + run(s, "variable.bind", json!({"ids": [f], "field": "fill", "variable": "c"})); + if let Some(m) = mode { + set_mode(s, f, json!(m)); + } + id(run(s, "component.create", json!({"ids": [f]}))) +} + +/// A set "Kind" whose variants A and B each set their own Tone mode. +fn kinds(s: &mut Session) -> (NodeId, NodeId) { + tone(s); + let a = swatch(s, "Kind=A", Some("A")); + let b = swatch(s, "Kind=B", Some("B")); + let set = id(run(s, "component.combineVariants", json!({"ids": [a, b]}))); + (set, b) +} + +fn instance(s: &mut Session, component: NodeId) -> NodeId { + id(run(s, "instance.create", json!({"component": component, "x": 200, "y": 0}))) +} + +fn switch(s: &mut Session, i: NodeId, kind: &str) { + run(s, "instance.setProperties", json!({"ids": [i], "properties": {"Kind": kind}})); +} + +#[test] +fn switching_variants_takes_the_new_variants_mode() { + let mut s = Session::new(); + let (set, _) = kinds(&mut s); + let i = instance(&mut s, set); + assert_eq!(fill(&s, i), "FF0000"); + switch(&mut s, i, "B"); + assert_eq!(fill(&s, i), "0000FF", "variant B sets mode B"); + switch(&mut s, i, "A"); + assert_eq!(fill(&s, i), "FF0000"); + assert!(s.doc().unwrap().node(i).unwrap().var_modes.is_empty(), "the instance sets no mode of its own"); +} + +#[test] +fn instances_follow_a_mode_their_main_sets_later() { + let mut s = Session::new(); + tone(&mut s); + let main = swatch(&mut s, "Chip", None); + let i = instance(&mut s, main); + assert_eq!(fill(&s, i), "FF0000", "no mode set: the collection's default"); + set_mode(&mut s, main, json!("B")); + assert_eq!(fill(&s, i), "0000FF"); + set_mode(&mut s, main, Value::Null); + assert_eq!(fill(&s, i), "FF0000", "the main back on Auto"); +} + +#[test] +fn a_mode_set_on_the_instance_wins_and_stays_through_variant_switches() { + let mut s = Session::new(); + let (set, _) = kinds(&mut s); + let i = instance(&mut s, set); + set_mode(&mut s, i, json!("B")); + assert_eq!(fill(&s, i), "0000FF"); + for kind in ["B", "A"] { + switch(&mut s, i, kind); + assert_eq!(fill(&s, i), "0000FF", "own mode B on variant {kind}"); + } + set_mode(&mut s, i, Value::Null); + assert_eq!(fill(&s, i), "FF0000", "back on Auto: variant A's mode"); +} + +#[test] +fn a_main_mode_is_closer_than_the_frame_around_the_instance() { + let mut s = Session::new(); + let (_, b) = kinds(&mut s); + let frame = id(run(&mut s, "frame.create", json!({"x": 0, "y": 300, "width": 200, "height": 100}))); + set_mode(&mut s, frame, json!("A")); + let i = id(run(&mut s, "instance.create", json!({"component": b, "x": 10, "y": 10, "parent": frame}))); + assert_eq!(fill(&s, i), "0000FF", "the main's mode B, not the frame's A"); + // Nested: an outer component on mode A holding an instance of B. + let outer_frame = id(run(&mut s, "frame.create", json!({"x": 0, "y": 500, "width": 100, "height": 100, "name": "Outer"}))); + run(&mut s, "instance.create", json!({"component": b, "x": 0, "y": 0, "parent": outer_frame})); + set_mode(&mut s, outer_frame, json!("A")); + let outer = id(run(&mut s, "component.create", json!({"ids": [outer_frame]}))); + let copy = instance(&mut s, outer); + let nested = s.doc().unwrap().node(copy).unwrap().children[0]; + assert_eq!(fill(&s, nested), "0000FF"); +} + +#[test] +fn detaching_keeps_the_mode_it_took_from_its_main() { + let mut s = Session::new(); + let (set, b) = kinds(&mut s); + let i = instance(&mut s, set); + switch(&mut s, i, "B"); + run(&mut s, "component.detach", json!({"ids": [i]})); + set_mode(&mut s, b, json!("A")); + 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 c32a99bb..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; @@ -49,6 +50,7 @@ pub mod proto_run; pub mod rename; pub mod rotation_origin; pub mod selection_colors; +pub mod selection_reorder; pub mod selection_spacing; pub mod shape_handles; pub mod shapebuilder; @@ -412,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); @@ -603,6 +606,23 @@ impl DocState { } } +/// The smallest and largest interface size, relative to the system's display scale (1.0 is the +/// system's own size). +pub const UI_SCALE_MIN: f64 = 0.75; +pub const UI_SCALE_MAX: f64 = 3.0; + +/// `scale` kept between [`UI_SCALE_MIN`] and [`UI_SCALE_MAX`]; not a finite number: `None`. +pub fn clamp_ui_scale(scale: f64) -> Option { + scale.is_finite().then(|| scale.clamp(UI_SCALE_MIN, UI_SCALE_MAX)) +} + +/// Reads the saved interface size. Anything but a number reads as 1 (the system's size) and a +/// number out of range is clamped, so a settings file edited by hand never costs the other +/// preferences. +fn read_ui_scale<'de, D: serde::Deserializer<'de>>(d: D) -> std::result::Result { + Ok(Value::deserialize(d)?.as_f64().and_then(clamp_ui_scale).unwrap_or(1.0)) +} + /// App-wide preferences that commands read (nudge amounts, behaviours). #[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] #[serde(default)] @@ -630,9 +650,16 @@ pub struct Prefs { /// Reduce motion: the interface's own animations are off (the system's setting turns them off /// too). Prototype transitions still play. pub reduce_motion: bool, + /// How large the whole interface is drawn, relative to the system's display scale: 1.0 is the + /// system's own size, 1.25 a quarter larger (kept between [`UI_SCALE_MIN`] and + /// [`UI_SCALE_MAX`]). The canvas zoom is separate. + #[serde(deserialize_with = "read_ui_scale")] + pub ui_scale: f64, /// Files that have a path are saved by themselves a few seconds after they change (the app's /// timer; off: only File ▸ Save writes them). pub auto_save: bool, + /// The desktop app starts on the Home screen instead of in a new file. + pub start_on_home: bool, /// `figcraft://` invite links open this app (`collab.registerLinks`); `None`: on for installed /// builds, off for development builds. pub invite_links: Option, @@ -698,7 +725,9 @@ impl Default for Prefs { language: "system".into(), user_name: cmd::comment::DEFAULT_AUTHOR.into(), reduce_motion: false, + ui_scale: 1.0, auto_save: false, + start_on_home: false, invite_links: None, plugins: Vec::new(), plugin_settings: BTreeMap::new(), @@ -735,6 +764,8 @@ pub struct Clipboard { /// The page-space bounds of the copied selection. pub bounds: Option, pub source_doc: u64, + /// Copied component/set roots, excluding cuts and originals discarded by undo (ids are reused). + pub copied_components: std::collections::BTreeSet, /// The document copied from (pastes into other files follow its mains) and its file path. pub source: Option>, pub source_path: Option, @@ -907,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())); @@ -1033,7 +1065,9 @@ impl Session { }; let Some(ds) = self.docs.iter_mut().find(|d| d.uid == uid) else { return }; let changed = !Arc::ptr_eq(&ds.doc, &bdoc); - if changed { + // Saving may adopt the chosen filename as the document title. The encoded snapshot is + // already final; normalising it again would detach it from the saved baseline. + if changed && !matches!(spec.id, "file.save" | "file.saveAs") { normalize(ds.doc_mut(), &bdoc); } let edit = changed && ds.interaction.is_none(); @@ -1098,8 +1132,11 @@ impl Session { let Some(it) = ds.interaction.clone() else { return Err(EngineError::Other("no gesture in progress".into())) }; let mut doc = (*it.doc).clone(); let mut sel = it.selection.clone(); + // What the gesture changes on the selected layers is an explicit edit. + let before = cmd::styles::bound_before(&doc, &it.selection); let res = f(&mut doc, &mut sel); if res.is_ok() { + cmd::styles::detach_edited(&mut doc, &before); normalize(&mut doc, &it.doc); // The same preview again (a handle held still) keeps the document: a new one would // re-render the canvas for nothing. @@ -1166,6 +1203,13 @@ impl Session { } ds.clean_selection(); ds.revision += 1; + let (uid, doc) = (ds.uid, ds.doc.clone()); + if self.clipboard.source_doc == uid { + self.clipboard.copied_components.retain(|id| { + doc.node(*id) + .is_some_and(|n| matches!(n.kind, figcraft_doc::NodeKind::Component { .. } | figcraft_doc::NodeKind::ComponentSet { .. })) + }); + } Ok(true) } @@ -1353,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) @@ -1434,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(); @@ -1460,21 +1509,35 @@ fn affects_layout(before: &Document, doc: &Document, id: NodeId) -> bool { } /// The layers whose variable bindings an edit may have changed: every bound layer when a variable -/// changed, else the touched layers and everything below those that moved, are new or chose -/// another variable mode (modes are inherited). +/// changed, else the touched layers and everything below those that moved, are new, chose +/// another variable mode or became an instance of another main (modes are inherited, an +/// instance's from its main too), and the instances of a main that chose another mode. fn binding_scope(doc: &Document, before: &Document, touched: &BTreeSet) -> Option> { if doc.variables != before.variables { return None; } + let main = |n: &Node| match n.kind { + figcraft_doc::NodeKind::Instance { component, .. } => Some(component), + _ => None, + }; let mut out = BTreeSet::new(); for &id in touched { let Some(n) = doc.node(id) else { continue }; - let inherited = before.node(id).is_none_or(|b| b.var_modes != n.var_modes || b.parent != n.parent); - if inherited { + let Some(b) = before.node(id) else { + out.extend(doc.subtree(id)); + continue; + }; + let modes = b.var_modes != n.var_modes; + if modes || b.parent != n.parent || main(b) != main(n) { out.extend(doc.subtree(id)); } else { out.insert(id); } + if modes && matches!(n.kind, figcraft_doc::NodeKind::Component { .. }) { + for i in components::instances_of(doc, id) { + out.extend(doc.subtree(i)); + } + } } Some(out) } @@ -1552,8 +1615,14 @@ 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; +#[cfg(test)] mod branch_tests; #[cfg(test)] mod branch_versions_tests; @@ -1596,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; @@ -1618,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; @@ -1650,6 +1725,8 @@ mod import_journal_tests; #[cfg(test)] mod inspect_behaviour_tests; #[cfg(test)] +mod instance_modes_tests; +#[cfg(test)] mod instance_resizing_tests; #[cfg(test)] mod interaction_rollback_tests; @@ -1718,6 +1795,8 @@ mod pixel_preview_tests; #[cfg(test)] mod playground_tests; #[cfg(test)] +mod plugin_data_tests; +#[cfg(test)] mod plugin_directory_tests; #[cfg(test)] mod plugin_panels_tests; @@ -1822,8 +1901,12 @@ mod variables_import_shapes_tests; #[cfg(test)] mod variables_io_tests; #[cfg(test)] +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; @@ -1836,5 +1919,7 @@ mod view_behaviour_tests; #[cfg(test)] mod widgets_tests; +#[cfg(test)] +mod selection_reorder_tests; #[cfg(test)] mod selection_spacing_tests; diff --git a/crates/engine/src/library.rs b/crates/engine/src/library.rs index 26863561..af322076 100644 --- a/crates/engine/src/library.rs +++ b/crates/engine/src/library.rs @@ -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/live.rs b/crates/engine/src/live.rs index 4001b156..cd8a0f03 100644 --- a/crates/engine/src/live.rs +++ b/crates/engine/src/live.rs @@ -81,11 +81,15 @@ pub const PEOPLE: [&str; 8] = ["#0D99FF", "#9747FF", "#14AE5C", "#F24822", "#D97 /// `#RRGGBB` → (r, g, b); `None` for anything else. pub fn parse_color(s: &str) -> Option<(u8, u8, u8)> { - let hex = s.strip_prefix('#').filter(|h| h.len() == 6 && h.is_ascii())?; + let hex = s.strip_prefix('#').filter(|h| h.len() == 6 && h.bytes().all(|b| b.is_ascii_hexdigit()))?; let byte = |i: usize| hex.get(i..i + 2).and_then(|b| u8::from_str_radix(b, 16).ok()); Some((byte(0)?, byte(2)?, byte(4)?)) } +/// Why a join failed when a message from the host is over this app's size limit (`{mb}`: the +/// limit in MB). The UI translates it. +pub const TOO_LARGE_TO_JOIN: &str = "This file is too large to join here (up to {mb} MB)"; + /// The connection to a live session. #[derive(Clone, Debug, Default, PartialEq, Serialize)] #[serde(rename_all = "camelCase", tag = "state")] diff --git a/crates/engine/src/live_access_tests.rs b/crates/engine/src/live_access_tests.rs index 7f5a4488..c2c75bb5 100644 --- a/crates/engine/src/live_access_tests.rs +++ b/crates/engine/src/live_access_tests.rs @@ -265,7 +265,7 @@ fn revert_offline_file_works_while_another_file_is_live_and_after_leaving() { s.execute("file.new", &json!({})).unwrap(); let offline_path = saved_file(&mut s); s.execute("file.revert", &json!({})).unwrap(); - assert_eq!(s.doc().unwrap().name, "Untitled"); + assert_eq!(s.doc().unwrap().name, offline_path.file_stem().unwrap().to_string_lossy()); assert!(!s.ds().unwrap().is_dirty()); assert_eq!(s.ds().unwrap().versions.len(), 1); assert_eq!(s.live_of(uid).doc_uid, Some(uid)); @@ -273,7 +273,7 @@ fn revert_offline_file_works_while_another_file_is_live_and_after_leaving() { s.execute("doc.switch", &json!({"index": 0})).unwrap(); s.execute("collab.leave", &json!({})).unwrap(); s.execute("file.revert", &json!({})).unwrap(); - assert_eq!(s.doc().unwrap().name, "Untitled"); + assert_eq!(s.doc().unwrap().name, path.file_stem().unwrap().to_string_lossy()); assert!(!s.ds().unwrap().is_dirty()); assert_eq!(s.ds().unwrap().versions.len(), 1); assert_eq!(s.live().doc_uid, None); diff --git a/crates/engine/src/live_tests.rs b/crates/engine/src/live_tests.rs index cacc4f37..5cdccf69 100644 --- a/crates/engine/src/live_tests.rs +++ b/crates/engine/src/live_tests.rs @@ -507,3 +507,11 @@ fn hosting_commands_check_what_they_are_given() { assert!(e.contains("join a file"), "{e}"); assert!(s.execute("collab.report", &json!({"reason": " \n "})).unwrap_err().to_string().contains("few words")); } + +#[test] +fn parse_color_rejects_signs_and_non_hex() { + use crate::live::parse_color; + assert_eq!(parse_color("#+1+2+3"), None); + assert_eq!(parse_color("#-1-2-3"), None); + assert_eq!(parse_color("#0D99FF"), Some((13, 153, 255))); +} 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/online.rs b/crates/engine/src/online.rs index b982580e..66713732 100644 --- a/crates/engine/src/online.rs +++ b/crates/engine/src/online.rs @@ -120,7 +120,12 @@ pub fn same_server(a: &str, b: &str) -> bool { let s = plain_server(s).to_ascii_lowercase(); let s = s.trim_end_matches('/'); let port = if s.starts_with("wss://") { ":443" } else { ":80" }; - s.strip_suffix(port).unwrap_or(s).to_string() + let start = s.find("://").map_or(0, |i| i + 3); + let end = s[start..].find('/').map_or(s.len(), |i| start + i); + match s[start..end].strip_suffix(port) { + Some(host) => format!("{}{}{}", &s[..start], host, &s[end..]), + None => s.to_string(), + } }; norm(a) == norm(b) } diff --git a/crates/engine/src/online_tests.rs b/crates/engine/src/online_tests.rs index 1e341b14..c211c505 100644 --- a/crates/engine/src/online_tests.rs +++ b/crates/engine/src/online_tests.rs @@ -583,3 +583,11 @@ fn debug_output_of_a_record_leaves_the_secrets_out() { no_secrets("debug", &text); assert!(text.contains("share.example") && text.contains(".."), "{text}"); } + +#[test] +fn same_server_ignores_the_default_port_before_a_path() { + use crate::online::same_server; + assert!(same_server("wss://share.example:443/figcraft/", "wss://share.example/figcraft/")); + assert!(same_server("ws://host:80/x", "ws://host/x")); + assert!(!same_server("wss://share.example:8443/figcraft/", "wss://share.example/figcraft/")); +} 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/plugin_data_tests.rs b/crates/engine/src/plugin_data_tests.rs new file mode 100644 index 00000000..076786b7 --- /dev/null +++ b/crates/engine/src/plugin_data_tests.rs @@ -0,0 +1,30 @@ +//! Layer data (`pluginData`) reads back through `node.get` and `document.inspect` (#879): what +//! `node.set` writes on a layer or a page comes back as `{key: value}`, a removed key goes, and a +//! layer without any reports none. + +use crate::Session; +use crate::components::tests::{ex, idv}; +use serde_json::json; + +#[test] +fn node_get_reports_the_layer_data_node_set_wrote() { + let mut s = Session::new(); + let f = idv(&ex(&mut s, "frame.create", json!({"x": 0, "y": 0, "width": 100, "height": 100}))); + let r = idv(&ex(&mut s, "shape.rectangle", json!({"x": 0, "y": 0, "width": 10, "height": 10}))); + let page = s.ds().unwrap().page; + assert!(ex(&mut s, "node.get", json!({"id": f})).get("pluginData").is_none(), "no data, no field"); + ex(&mut s, "node.set", json!({"ids": [f], "props": {"pluginData": {"com.example.sync": "{\"v\":1}", "n": 2}}})); + ex(&mut s, "node.set", json!({"ids": [page], "props": {"pluginData": {"com.example.sync": "page"}}})); + let got = ex(&mut s, "node.get", json!({"id": f}))["pluginData"].clone(); + assert_eq!(got, json!({"com.example.sync": "{\"v\":1}", "n": "2"}), "values read back as the strings stored"); + assert_eq!(ex(&mut s, "node.get", json!({"id": page}))["pluginData"], json!({"com.example.sync": "page"}), "pages keep data too"); + assert!(ex(&mut s, "node.get", json!({"id": r})).get("pluginData").is_none(), "other layers report none"); + // The document tree reports it as well (the page and its children). + let tree = crate::inspect::document(&s, 2, false); + assert_eq!(tree["pages"][0]["pluginData"], json!({"com.example.sync": "page"})); + // A null removes a key; undo brings it back. + ex(&mut s, "node.set", json!({"ids": [f], "props": {"pluginData": {"n": null}}})); + assert_eq!(ex(&mut s, "node.get", json!({"id": f}))["pluginData"], json!({"com.example.sync": "{\"v\":1}"})); + ex(&mut s, "edit.undo", json!({})); + assert_eq!(ex(&mut s, "node.get", json!({"id": f}))["pluginData"]["n"], "2"); +} 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 4473ef7c..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() } } @@ -89,6 +93,46 @@ fn assert_save_state_unchanged(s: &Session, before: &DocState, recent: &[String] assert_eq!(s.prefs.recent_files, recent); } +#[test] +fn first_native_save_uses_chosen_filename_for_an_untitled_document() { + let mut files = SaveFiles::new(); + let path = files.path("Redis.figcraft"); + let copy = files.path("Redis copy.figcraft"); + let manual_copy = files.path("Other.figcraft"); + let mut s = Session::new(); + s.execute("file.saveAs", &json!({"path": path.to_string_lossy()})).unwrap(); + assert_eq!(s.ds().unwrap().doc.name, "Redis"); + assert!(!s.ds().unwrap().is_dirty()); + assert_eq!(figcraft_format::load_bytes(&std::fs::read(&path).unwrap()).unwrap().0.name, "Redis"); + + let mut reopened = Session::empty(); + reopened.execute("file.open", &json!({"path": path.to_string_lossy()})).unwrap(); + assert_eq!(reopened.ds().unwrap().doc.name, "Redis"); + + s.execute("file.saveAs", &json!({"path": copy.to_string_lossy()})).unwrap(); + assert_eq!(s.ds().unwrap().doc.name, "Redis copy"); + s.execute("file.rename", &json!({"name": "Chosen title"})).unwrap(); + s.execute("file.saveAs", &json!({"path": manual_copy.to_string_lossy()})).unwrap(); + assert_eq!(s.ds().unwrap().doc.name, "Chosen title"); +} + +#[test] +fn opening_an_older_file_uses_its_filename_only_for_the_default_title() { + let mut files = SaveFiles::new(); + let old_path = files.path("Redis.figcraft"); + let named_path = files.path("Other.figcraft"); + let mut original = Session::new(); + std::fs::write(&old_path, crate::cmd::file::file_bytes(original.ds().unwrap()).unwrap()).unwrap(); + original.execute("file.rename", &json!({"name": "Chosen title"})).unwrap(); + std::fs::write(&named_path, crate::cmd::file::file_bytes(original.ds().unwrap()).unwrap()).unwrap(); + + let mut reopened = Session::empty(); + reopened.execute("file.open", &json!({"path": old_path.to_string_lossy()})).unwrap(); + assert_eq!(reopened.ds().unwrap().doc.name, "Redis"); + reopened.execute("file.open", &json!({"path": named_path.to_string_lossy()})).unwrap(); + assert_eq!(reopened.ds().unwrap().doc.name, "Chosen title"); +} + fn read_fig(path: &std::path::Path, result: &Value) -> figcraft_doc::Document { let bytes = std::fs::read(path).unwrap(); assert_eq!(result["format"], "fig"); 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/selection_colors.rs b/crates/engine/src/selection_colors.rs index e604c798..875c6416 100644 --- a/crates/engine/src/selection_colors.rs +++ b/crates/engine/src/selection_colors.rs @@ -9,7 +9,7 @@ use figcraft_doc::{Document, NodeId, NodeKind, StyleKind}; use serde::{Deserialize, Serialize}; use serde_json::{Value, json}; -use crate::cmd::lists::{BoundList, list_binding}; +use crate::cmd::lists::{BoundList, list_binding, rebind_edited_paint}; use crate::params::*; use crate::{CommandSpec, Result, Session, bad, spec}; @@ -153,37 +153,6 @@ fn checked_targets(s: &Session, p: &Value, cmd: &str) -> Result Ok(ts) } -/// Detach variables only where the explicit edit changed their value. In particular, an -/// opacity edit keeps the color binding and a range edit keeps other characters' node bindings. -fn unbind_changed(n: &mut figcraft_doc::Node, t: &PaintTarget, old: &Paint, new: &Paint) { - // Range fills override the node's paints; the node binding still belongs to other chars. - if t.range.is_some() { - return; - } - let list = if t.fills { BoundList::Fills } else { BoundList::Strokes }; - n.bound.retain(|key, _| { - let Some((l, index, field)) = list_binding(key) else { return true }; - if l != list || index != t.index { - return true; - } - match field { - "color" => match (&old.kind, &new.kind) { - (PaintKind::Solid { color: a }, PaintKind::Solid { color: b }) => a == b, - _ => false, - }, - "opacity" => old.opacity == new.opacity, - _ => { - let stop = field.strip_prefix("stops/").and_then(|s| s.strip_suffix("/color")).and_then(|s| s.parse::().ok()); - match (stop, &old.kind, &new.kind) { - (Some(i), PaintKind::Gradient(a), PaintKind::Gradient(b)) => a.stops.get(i).map(|s| s.color) == b.stops.get(i).map(|s| s.color), - (Some(_), _, _) => false, - _ => old == new, - } - } - } - }); -} - fn set_paint(s: &mut Session, p: &Value) -> Result { const CMD: &str = "selection.setPaint"; let targets = checked_targets(s, p, CMD)?; @@ -197,18 +166,22 @@ fn set_paint(s: &mut Session, p: &Value) -> Result { let old = slot.clone(); *slot = paint.clone(); let n = s.doc_mut()?.get_mut(target.id)?; - unbind_changed(n, target, &old, &paint); + // Variables detach only where the edit changed their value; range fills override the node's + // paints, whose bindings still belong to the other characters. if let Some((start, end)) = target.range { if let NodeKind::Text(t) = &mut n.kind { t.restyle(start, end, |style| style.fills = Some(paints.clone())); } - } else if target.fills { + continue; + } + if target.fills { n.fills = paints; n.styles.fill = None; } else { n.strokes = paints; n.styles.stroke = None; } + rebind_edited_paint(n, target.fills, target.index, &old); } Ok(json!({"ok": true})) } diff --git a/crates/engine/src/selection_reorder.rs b/crates/engine/src/selection_reorder.rs new file mode 100644 index 00000000..d54109c7 --- /dev/null +++ b/crates/engine/src/selection_reorder.rs @@ -0,0 +1,106 @@ +//! Geometry for reordering a smart selection. Only regular, non-overlapping rows, columns +//! and complete top-left-aligned grids have unambiguous insertion slots. +//! Marking several items and irregular grids are out of scope. +use figcraft_doc::{Document, NodeId}; +use figcraft_geom::{Point, Rect}; + +use crate::selection_spacing::selection_spacing; + +#[derive(Clone, Debug)] +pub struct ReorderLayout { + /// Visual order, independent of selection order and layer stacking. + pub slots: Vec<(NodeId, Rect)>, + columns: usize, + rows: usize, + gap_x: f64, + gap_y: f64, +} + +impl ReorderLayout { + pub fn new(doc: &Document, ids: &[NodeId]) -> Option { + let layout = selection_spacing(doc, ids); + let h = layout.horizontal.as_ref(); + let v = layout.vertical.as_ref(); + let gap = |axis: Option<&crate::selection_spacing::Axis>| match axis { + Some(a) => a.value.filter(|v| *v >= 0.0), + None => Some(0.0), + }; + let (gap_x, gap_y) = (gap(h)?, gap(v)?); + let columns = h.map_or(1, |a| a.tracks.len()); + let rows = v.map_or(1, |a| a.tracks.len()); + if ids.len() < 2 || columns.checked_mul(rows)? != ids.len() { + return None; + } + let mut cells = std::collections::BTreeMap::new(); + for id in ids { + let col = match h { + Some(a) => a.tracks.iter().position(|t| t.ids.contains(id))?, + None => 0, + }; + let row = match v { + Some(a) => a.tracks.iter().position(|t| t.ids.contains(id))?, + None => 0, + }; + let b = doc.world_bounds(*id)?; + if columns > 1 && rows > 1 { + let x = h?.tracks.get(col)?.bounds.x0; + let y = v?.tracks.get(row)?.bounds.y0; + if (b.x0 - x).abs() > 0.01 || (b.y0 - y).abs() > 0.01 { + return None; + } + } + if cells.insert((row, col), (*id, b)).is_some() { + return None; + } + } + Some(Self { slots: cells.into_values().collect(), columns, rows, gap_x, gap_y }) + } + + /// Insertion shifts intervening items; swapping exchanges just two entries. Sizes and + /// original gaps are retained, so unequal-sized layers reflow instead of overlapping. + pub fn positions(&self, source: NodeId, target: NodeId, swap: bool) -> Option> { + let from = self.slots.iter().position(|(id, _)| *id == source)?; + let to = self.slots.iter().position(|(id, _)| *id == target)?; + if from == to { + return Some(self.slots.iter().map(|(id, b)| (*id, Point::new(b.x0, b.y0))).collect()); + } + let mut order = self.slots.clone(); + if swap { + order.swap(from, to); + } else { + let item = order.remove(from); + order.insert(to, item); + } + let mut widths = vec![0.0_f64; self.columns]; + let mut heights = vec![0.0_f64; self.rows]; + for (i, (_, b)) in order.iter().enumerate() { + let w = widths.get_mut(i % self.columns)?; + *w = w.max(b.width()); + let h = heights.get_mut(i / self.columns)?; + *h = h.max(b.height()); + } + let x0 = self.slots.iter().map(|(_, b)| b.x0).fold(f64::INFINITY, f64::min); + let y0 = self.slots.iter().map(|(_, b)| b.y0).fold(f64::INFINITY, f64::min); + let starts = |sizes: &[f64], mut at: f64, gap: f64| { + sizes + .iter() + .map(|size| { + let start = at; + at += size + gap; + start + }) + .collect::>() + }; + let xs = starts(&widths, x0, self.gap_x); + let ys = starts(&heights, y0, self.gap_y); + order + .iter() + .enumerate() + .map(|(i, (id, b))| { + let x = if self.columns == 1 { b.x0 } else { *xs.get(i % self.columns)? }; + let y = if self.rows == 1 { b.y0 } else { *ys.get(i / self.columns)? }; + Some((*id, Point::new(x, y))) + }) + .collect() + } +} diff --git a/crates/engine/src/selection_reorder_tests.rs b/crates/engine/src/selection_reorder_tests.rs new file mode 100644 index 00000000..83ae5b31 --- /dev/null +++ b/crates/engine/src/selection_reorder_tests.rs @@ -0,0 +1,152 @@ +use crate::{Session, selection_reorder::ReorderLayout}; +use figcraft_doc::NodeId; +use serde_json::json; + +fn rect(s: &mut Session, x: f64, y: f64, w: f64, h: f64, parent: Option) -> NodeId { + NodeId(s.execute("shape.rectangle", &json!({"x":x,"y":y,"width":w,"height":h,"parent":parent})).unwrap()["id"].as_u64().unwrap()) +} +fn xy(s: &Session, id: NodeId) -> (f64, f64) { + let b = s.doc().unwrap().world_bounds(id).unwrap(); + (b.x0, b.y0) +} + +#[test] +fn insertion_reflows_unequal_sizes_with_original_gaps_and_hierarchy() { + for vertical in [false, true] { + let mut s = Session::new(); + let mut ids = vec![]; + for (at, size) in [(0., 40.), (50., 60.), (120., 20.)] { + ids.push(rect( + &mut s, + if vertical { 0. } else { at }, + if vertical { at } else { 0. }, + if vertical { 30. } else { size }, + if vertical { size } else { 30. }, + None, + )); + } + let before = s.doc().unwrap().clone(); + // Explicit selection order must not determine visual order. + s.execute("arrange.reorder", &json!({"ids":[ids[2],ids[0],ids[1]],"source":ids[0],"target":ids[2]})).unwrap(); + for (id, at) in [(ids[1], 0.), (ids[2], 70.), (ids[0], 100.)] { + assert_eq!(xy(&s, id), if vertical { (0., at) } else { (at, 0.) }); + let old = before.node(id).unwrap(); + let new = s.doc().unwrap().node(id).unwrap(); + assert_eq!((new.width, new.height, new.parent), (old.width, old.height, old.parent)); + } + assert_eq!(s.doc().unwrap().node(s.ds().unwrap().page).unwrap().children, before.node(s.ds().unwrap().page).unwrap().children); + s.undo().unwrap(); + assert_eq!(s.doc().unwrap(), &before); + s.redo().unwrap(); + assert_eq!(xy(&s, ids[0]), if vertical { (0., 100.) } else { (100., 0.) }); + } +} + +#[test] +fn grids_insert_across_rows_and_swap_only_the_two_entries() { + let mut s = Session::new(); + let mut ids = vec![]; + for row in 0..2 { + for col in 0..3 { + ids.push(rect(&mut s, col as f64 * 70., row as f64 * 60., 50., 40., None)); + } + } + let before = s.doc().unwrap().clone(); + s.execute("arrange.reorder", &json!({"ids":ids,"source":ids[0],"target":ids[4]})).unwrap(); + assert_eq!(xy(&s, ids[0]), (70., 60.)); + assert_eq!(xy(&s, ids[3]), (140., 0.)); + assert_eq!(xy(&s, ids[5]), (140., 60.)); + s.undo().unwrap(); + s.execute("arrange.reorder", &json!({"ids":ids,"source":ids[0],"target":ids[4],"swap":true})).unwrap(); + assert_eq!(xy(&s, ids[0]), (70., 60.)); + assert_eq!(xy(&s, ids[4]), (0., 0.)); + for id in [ids[1], ids[2], ids[3], ids[5]] { + assert_eq!(s.doc().unwrap().node(id), before.node(id)); + } +} + +#[test] +fn grid_reflow_keeps_gaps_with_different_sizes() { + let mut s = Session::new(); + let a = rect(&mut s, 0., 0., 40., 30., None); + let b = rect(&mut s, 50., 0., 60., 30., None); + let c = rect(&mut s, 0., 50., 40., 50., None); + let d = rect(&mut s, 50., 50., 60., 50., None); + s.execute("arrange.reorder", &json!({"ids":[a,b,c,d],"source":a,"target":d})).unwrap(); + assert_eq!(xy(&s, b), (0., 0.)); + assert_eq!(xy(&s, c), (70., 0.)); + assert_eq!(xy(&s, d), (0., 70.)); + assert_eq!(xy(&s, a), (70., 70.)); +} + +#[test] +fn previews_are_absolute_and_returning_to_start_is_a_noop() { + let mut s = Session::new(); + let ids: Vec<_> = (0..3).map(|i| rect(&mut s, i as f64 * 70., 0., 50., 40., None)).collect(); + s.execute("select.set", &json!({"ids":ids})).unwrap(); + let before = s.doc().unwrap().clone(); + let history = s.undo_depth().0; + s.begin_interaction("Reorder selection").unwrap(); + for target in [ids[2], ids[1], ids[0]] { + s.execute("arrange.reorder", &json!({"ids":ids,"source":ids[0],"target":target,"fromGesture":true})).unwrap(); + } + s.commit_interaction().unwrap(); + assert_eq!(s.doc().unwrap(), &before); + assert_eq!(s.undo_depth().0, history); + s.begin_interaction("Reorder selection").unwrap(); + for target in [ids[1], ids[2]] { + s.execute("arrange.reorder", &json!({"ids":ids,"source":ids[0],"target":target,"fromGesture":true})).unwrap(); + } + s.commit_interaction().unwrap(); + assert_eq!(s.undo_depth().0, history + 1); + assert_eq!(s.selection(), ids); + s.undo().unwrap(); + assert_eq!(s.doc().unwrap(), &before); +} + +#[test] +fn invalid_selections_and_targets_do_not_edit_the_document() { + let mut s = Session::new(); + let a = rect(&mut s, 0., 0., 50., 40., None); + let b = rect(&mut s, 70., 0., 50., 40., None); + let c = rect(&mut s, 160., 0., 50., 40., None); + for args in [ + json!({"ids":[a,b,c],"source":a,"target":b}), // Mixed gaps. + json!({"ids":[a,b],"source":c,"target":b}), + json!({"ids":[a,b],"source":a,"target":999999}), + json!({"ids":[a,a,b],"source":a,"target":b}), + json!({"ids":[a,b],"source":[],"target":b}), + ] { + let before = s.doc().unwrap().clone(); + assert!(s.execute("arrange.reorder", &args).is_err()); + assert_eq!(s.doc().unwrap(), &before); + } + for props in [json!({"locked":true}), json!({"visible":false})] { + s.execute("node.set", &json!({"id":b,"props":props})).unwrap(); + assert!(ReorderLayout::new(s.doc().unwrap(), &[a, b]).is_none()); + s.execute("node.set", &json!({"id":b,"props":{"locked":false,"visible":true}})).unwrap(); + } + let d = rect(&mut s, 0., 70., 50., 40., None); + assert!(ReorderLayout::new(s.doc().unwrap(), &[a, b, d]).is_none(), "an incomplete grid has no slots"); + let frame = + NodeId(s.execute("frame.create", &json!({"width":300,"height":100,"props":{"layout":"horizontal"}})).unwrap()["id"].as_u64().unwrap()); + let x = rect(&mut s, 0., 0., 50., 40., Some(frame)); + let y = rect(&mut s, 70., 0., 50., 40., Some(frame)); + assert!(ReorderLayout::new(s.doc().unwrap(), &[x, y]).is_none()); + assert!(ReorderLayout::new(s.doc().unwrap(), &[a, x]).is_none()); +} + +#[test] +fn reordering_children_of_a_rotated_parent_uses_page_coordinates() { + let mut s = Session::new(); + let frame = NodeId( + s.execute("frame.create", &json!({"x":200,"y":200,"width":300,"height":100,"props":{"rotation":90}})).unwrap()["id"].as_u64().unwrap(), + ); + let a = rect(&mut s, 0., 0., 50., 40., Some(frame)); + let b = rect(&mut s, 70., 0., 50., 40., Some(frame)); + let (pa, pb) = (xy(&s, a), xy(&s, b)); + s.execute("arrange.reorder", &json!({"ids":[a,b],"source":a,"target":b})).unwrap(); + let (qa, qb) = (xy(&s, a), xy(&s, b)); + assert!((qa.0 - pb.0).abs() < 1e-7 && (qa.1 - pb.1).abs() < 1e-7); + assert!((qb.0 - pa.0).abs() < 1e-7 && (qb.1 - pa.1).abs() < 1e-7); +} 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/slot_tests.rs b/crates/engine/src/slot_tests.rs index 516cb9d6..cbf824d9 100644 --- a/crates/engine/src/slot_tests.rs +++ b/crates/engine/src/slot_tests.rs @@ -41,6 +41,17 @@ fn props_of(s: &mut Session, id: NodeId) -> Vec { ex(s, "component.properties", json!({"id": id}))["properties"].as_array().cloned().unwrap_or_default() } +/// A refused slot is reported by `component.makeSlot`, the command that was run, not by the +/// property command it adds its slot property through. +#[test] +fn a_refused_slot_names_make_slot() { + let mut s = Session::new(); + let (c, ..) = slot_card(&mut s); + let other = idv(&ex(&mut s, "frame.create", json!({"x": 150, "y": 10, "width": 40, "height": 30, "parent": c, "name": "Other"}))); + let err = s.execute("component.makeSlot", &json!({"id": other, "name": "Content"})).unwrap_err().to_string(); + assert!(err.contains("`component.makeSlot`") && err.contains("a property named `Content` already exists"), "{err}"); +} + #[test] fn slots_are_frames_of_a_main_with_a_slot_property() { let mut s = Session::new(); 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/tests.rs b/crates/engine/src/tests.rs index d8ebfc9e..646c7c4b 100644 --- a/crates/engine/src/tests.rs +++ b/crates/engine/src/tests.rs @@ -376,6 +376,7 @@ fn pointer_draw_move_resize() { assert_eq!(s.selection(), vec![r]); } +/// No command panics on junk parameters. #[test] fn every_command_survives_junk_params() { // A junk-parameter sweep: no command may panic (guard turns panics into Internal errors, @@ -418,6 +419,9 @@ fn every_command_survives_junk_params() { // Selection color slots: malformed indexes, ranges and paints. json!({"targets": [{"id": 2, "fills": true, "index": -1, "range": [0, 1e308]}], "paint": {"type": "solid", "color": "invalid"}}), json!({"targets": [{"id": 2, "fills": "yes", "index": 0, "range": [4, 1]}], "style": 5}), + // Smart-selection reorder targets and modes as junk. + json!({"ids": [2, 3], "source": -1, "target": {"id": 3}, "swap": [], "fromGesture": true}), + json!({"source": 1e308, "target": "NaN", "swap": "yes", "fromGesture": []}), // Rotation origins and placing with a tool as junk. json!({"tool": [1], "x": 1e308, "y": "NaN", "reset": "maybe", "on": {"x": 1}}), json!({"tool": "frame", "x": -1e308, "y": 1e308, "reset": true, "on": "yes"}), @@ -434,6 +438,8 @@ fn every_command_survives_junk_params() { | "file.placeImage" | "image.place" | "file.revert" + // Takes no params and walks the font folders: one walk per junk is slow. + | "text.rescanFonts" ) { continue; } 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 76b353a0..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(()) } @@ -782,6 +787,7 @@ mod tests { assert!(r.is_null() || r["axes"].is_array()); } + /// No text command panics on junk parameters while a text is being edited. #[test] fn text_commands_survive_junk_while_editing() { let junk = [ @@ -793,7 +799,8 @@ mod tests { json!({"variations": {"wght": "heavy", "toolong": 1}, "text": "\u{0}\u{7}", "cursor": 1e300}), json!({"variations": {"wght": 1e300, "opsz": -1e300}, "replace": "yes", "text": "日本語", "cursor": -1}), ]; - for spec in crate::cmd::all_commands().iter().filter(|c| c.id.starts_with("text.")) { + // `text.rescanFonts` takes no params and walks the font folders: one walk per junk is slow. + for spec in crate::cmd::all_commands().iter().filter(|c| c.id.starts_with("text.") && c.id != "text.rescanFonts") { for j in &junk { let (mut s, id) = setup("Hi there\nyou"); begin(&mut s, id, Some(2), false).unwrap(); diff --git a/crates/engine/src/tools.rs b/crates/engine/src/tools.rs index ad4d78d8..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() { @@ -1300,8 +1401,11 @@ impl Session { for i in &ids { let parent = doc.get(*i)?.parent.unwrap_or(page); let idx = doc.index_in_parent(*i).map(|x| x + 1); - copies.push(doc.duplicate_into(*i, parent, idx)?); + 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/variables_behaviour_tests.rs b/crates/engine/src/variables_behaviour_tests.rs index 5a868a63..3fe156f8 100644 --- a/crates/engine/src/variables_behaviour_tests.rs +++ b/crates/engine/src/variables_behaviour_tests.rs @@ -279,6 +279,34 @@ fn bindings_by_field_and_type() { } } +#[test] +fn auto_layout_bindings_go_with_the_auto_layout() { + // Docs silent; pinned (#810): removing a frame's auto layout drops its gap and padding bindings, + // and a binding never brings an auto layout back. One made without auto layout waits for it. + let mut s = Session::new(); + run(&mut s, "variable.createCollection", json!({"name": "C"})); + run(&mut s, "variable.create", json!({"collection": "C", "name": "g", "type": "number", "value": 12})); + let removals = + [("layout.remove", json!({})), ("node.set", json!({"props": {"layout": null}})), ("node.set", json!({"props": {"layout": "none"}}))]; + for (cmd, mut p) in removals { + let f = frame(&mut s, None); + run(&mut s, "layout.add", json!({"ids": [f]})); + for field in ["gap", "crossGap", "paddingTop"] { + run(&mut s, "variable.bind", json!({"ids": [f], "field": field, "variable": "g"})); + } + p["ids"] = json!([f]); + run(&mut s, cmd, p.clone()); + let n = node(&s, f); + assert!(n.layout.is_none(), "{cmd} {p}: auto layout stays removed"); + assert!(n.bound.is_empty(), "{cmd} {p}: layout bindings dropped"); + } + let f = frame(&mut s, None); + run(&mut s, "variable.bind", json!({"ids": [f], "field": "gap", "variable": "g"})); + assert!(node(&s, f).layout.is_none(), "binding gap adds no auto layout"); + run(&mut s, "layout.add", json!({"ids": [f]})); + assert_eq!(node(&s, f).layout.map(|l| l.gap), Some(12.0), "the waiting binding applies to the new auto layout"); +} + #[test] fn deleting_a_bound_variable_keeps_the_last_value() { // Documented: deleting a variable unbinds what used it, which keeps showing its last value. @@ -365,3 +393,90 @@ fn number_variables_are_pixels_and_edits_are_one_undo_step() { s.undo().unwrap(); assert_eq!(lh(&s), LineHeight::Pixels(20.0), "the value and the bound layer go back together"); } + +#[test] +fn create_collection_keeps_at_least_one_named_mode() { + // A collection keeps at least one mode (removeMode) and mode names can't be empty (renameMode), + // so createCollection refuses an empty mode list, blank mode names and a blank collection name. + let mut s = Session::new(); + for p in [ + json!({"name": "C", "modes": []}), + json!({"name": "C", "modes": [1]}), + json!({"name": "C", "modes": ["", " "]}), + json!({"name": "C", "modes": ["A", " "]}), + json!({"name": " "}), + json!({"name": ""}), + ] { + assert!(s.execute("variable.createCollection", &p).is_err(), "{p} should be refused"); + } + assert!(s.doc().unwrap().variables.collections.is_empty(), "a refused call creates nothing"); + run(&mut s, "variable.createCollection", json!({"name": "C", "modes": [" Light ", "Dark"]})); + let names: Vec = s.doc().unwrap().variables.collections[0].modes.iter().map(|m| m.name.clone()).collect(); + assert_eq!(names, ["Light", "Dark"]); +} + +#[test] +fn explicit_edits_of_bound_fields_stick_and_detach_that_binding() { + // #809: an edit of a bound field detaches its binding (as a paint edit does) instead of + // the variable's value coming back; bindings whose value the edit kept stay. + let mut s = Session::new(); + run(&mut s, "variable.createCollection", json!({"name": "C"})); + let w = vid(run(&mut s, "variable.create", json!({"collection": "C", "name": "w", "type": "number", "value": 48}))); + let o = vid(run(&mut s, "variable.create", json!({"collection": "C", "name": "o", "type": "number", "value": 40}))); + let r = rect(&mut s, None); + for (field, v) in [("width", &w), ("height", &w), ("opacity", &o)] { + run(&mut s, "variable.bind", json!({"ids": [r], "field": field, "variable": v})); + } + assert_eq!((node(&s, r).width, node(&s, r).opacity), (48.0, 0.4)); + + run(&mut s, "node.set", json!({"ids": [r], "width": 100, "opacity": 0.4})); + let n = node(&s, r); + assert_eq!(n.width, 100.0); + assert!(!n.bound.contains_key("width")); + assert!(n.bound.contains_key("height") && n.bound.contains_key("opacity"), "unchanged values keep their bindings"); + + run(&mut s, "transform.resize", json!({"ids": [r], "height": 30})); + assert_eq!(node(&s, r).height, 30.0); + assert!(!node(&s, r).bound.contains_key("height")); + + run(&mut s, "object.opacityKeys", json!({"ids": [r], "digits": "9"})); + assert_eq!(node(&s, r).opacity, 0.9); + assert!(node(&s, r).bound.is_empty()); + + // A value set with its binding in the same call keeps the new binding. + run(&mut s, "node.set", json!({"ids": [r], "width": 5, "boundVariables": {"width": w}})); + assert_eq!((node(&s, r).width, node(&s, r).bound.get("width")), (48.0, Some(&w))); +} + +#[test] +fn a_gesture_or_a_text_style_on_a_bound_field_detaches_it() { + let mut s = Session::new(); + run(&mut s, "variable.createCollection", json!({"name": "C"})); + let v = vid(run(&mut s, "variable.create", json!({"collection": "C", "name": "r", "type": "number", "value": 8}))); + let r = rect(&mut s, None); + run(&mut s, "variable.bind", json!({"ids": [r], "field": "cornerRadius", "variable": v})); + s.begin_interaction("Edit").unwrap(); + let props = serde_json::Map::from_iter([("cornerRadius".to_string(), json!(2))]); + s.preview(|d, _| crate::cmd::props::apply("node.set", d, r, &props)).unwrap(); + s.commit_interaction().unwrap(); + assert_eq!(node(&s, r).corners.radii, [2.0; 4]); + assert!(node(&s, r).bound.is_empty()); + + let t = id(run(&mut s, "text.create", json!({"x": 0, "y": 0, "text": "Hi"}))); + run(&mut s, "variable.bind", json!({"ids": [t], "field": "fontSize", "variable": v})); + run(&mut s, "text.style", json!({"id": t, "fontSize": 20})); + assert!(node(&s, t).bound.is_empty()); + assert_eq!(node(&s, t).kind.text().unwrap().style.font_size, 20.0); + + // The font size/weight/tracking shortcuts and text content edits detach too. + run(&mut s, "variable.bind", json!({"ids": [t], "field": "fontSize", "variable": v})); + assert_eq!(node(&s, t).kind.text().unwrap().style.font_size, 8.0); + run(&mut s, "text.sizeUp", json!({"ids": [t]})); + assert_eq!(node(&s, t).kind.text().unwrap().style.font_size, 9.0); + assert!(node(&s, t).bound.is_empty()); + let c = vid(run(&mut s, "variable.create", json!({"collection": "C", "name": "c", "type": "string", "value": "Hello"}))); + run(&mut s, "variable.bind", json!({"ids": [t], "field": "characters", "variable": c})); + run(&mut s, "text.set", json!({"id": t, "characters": "Bye"})); + assert_eq!(node(&s, t).kind.text().unwrap().characters, "Bye"); + assert!(node(&s, t).bound.is_empty()); +} diff --git a/crates/engine/src/variables_io_tests.rs b/crates/engine/src/variables_io_tests.rs index 5c7305bb..3a57d9ea 100644 --- a/crates/engine/src/variables_io_tests.rs +++ b/crates/engine/src/variables_io_tests.rs @@ -371,3 +371,29 @@ fn variant_properties_follow_string_variables() { assert_eq!(main(&s), variants[1]); healthy(&s); } + +#[test] +fn paints_replaced_through_node_set_keep_unchanged_stop_bindings() { + let mut s = Session::new(); + let r = gradient_rect(&mut s); + ex(&mut s, "variable.bind", json!({"ids": [r], "field": "fills/0/stops/2/color", "variable": "brand"})); + ex(&mut s, "variable.bind", json!({"ids": [r], "field": "fills/0/stops/1/color", "variable": "ink"})); + let cur = |s: &Session| s.doc().unwrap().node(r).unwrap().fills[0].clone(); + // Removing the first stop moves both bindings down with their stops. + let mut p = cur(&s); + if let PaintKind::Gradient(g) = &mut p.kind { + g.stops.remove(0); + } + ex(&mut s, "node.set", json!({"ids": [r], "props": {"fills": [p]}})); + assert_eq!(bound_keys(&s, r), ["fills/0/stops/0/color", "fills/0/stops/1/color"]); + // Recolouring one bound stop detaches that stop only. + let mut p = cur(&s); + if let PaintKind::Gradient(g) = &mut p.kind { + g.stops[0].color = Color::WHITE; + } + ex(&mut s, "node.set", json!({"ids": [r], "props": {"fills": [p]}})); + assert_eq!(bound_keys(&s, r), ["fills/0/stops/1/color"]); + ex(&mut s, "variable.set", json!({"variable": "brand", "value": "#0000ff"})); + assert_eq!(stops(&s, r), ["FFFFFF", "0000FF"]); + healthy(&s); +} diff --git a/crates/engine/src/variants_discovery_tests.rs b/crates/engine/src/variants_discovery_tests.rs new file mode 100644 index 00000000..b792e727 --- /dev/null +++ b/crates/engine/src/variants_discovery_tests.rs @@ -0,0 +1,179 @@ +//! Combine as variants and Add variant where people look for them (#903): the predicates the +//! context menu and the Design panel offer them by, combining refusing components that are +//! variants already (it used to put a set inside a set), and Add variant on a main component that +//! isn't in a set yet making it one. + +use serde_json::json; + +use crate::Session; +use crate::cmd::component::{can_add_variant, can_combine_variants}; +use crate::components::tests::{ex, healthy, idv, kids, node}; +use figcraft_doc::*; + +/// A main component named `name` at `x`, made from a 120×40 frame with a "Bg" rectangle. +fn component(s: &mut Session, name: &str, x: f64) -> NodeId { + let f = idv(&ex(s, "frame.create", json!({"x": x, "y": 0, "width": 120, "height": 40, "name": name}))); + ex(s, "shape.rectangle", json!({"x": 0, "y": 0, "width": 120, "height": 40, "parent": f, "props": {"name": "Bg"}})); + idv(&ex(s, "component.create", json!({"ids": [f]}))) +} + +fn undo_len(s: &Session) -> usize { + s.ds().unwrap().history.undo.len() +} + +fn sets(s: &Session) -> usize { + s.doc().unwrap().nodes().filter(|n| matches!(n.kind, NodeKind::ComponentSet { .. })).count() +} + +#[test] +fn combine_is_offered_for_two_or_more_main_components_only() { + let mut s = Session::new(); + let a = component(&mut s, "A", 0.0); + let b = component(&mut s, "B", 200.0); + let frame = idv(&ex(&mut s, "frame.create", json!({"x": 0, "y": 300, "width": 50, "height": 50}))); + let inst = idv(&ex(&mut s, "instance.create", json!({"component": a, "x": 0, "y": 400}))); + let doc = s.doc().unwrap(); + assert!(can_combine_variants(doc, &[a, b])); + assert!(!can_combine_variants(doc, &[]), "nothing selected"); + assert!(!can_combine_variants(doc, &[a]), "one component (the command still takes it)"); + assert!(!can_combine_variants(doc, &[a, a]), "the same component twice"); + assert!(!can_combine_variants(doc, &[a, frame]), "a frame isn't a component"); + assert!(!can_combine_variants(doc, &[a, inst]), "an instance isn't a main component"); + + let c = component(&mut s, "C", 400.0); + let d = component(&mut s, "D", 600.0); + let set = idv(&ex(&mut s, "component.combineVariants", json!({"ids": [c, d]}))); + let doc = s.doc().unwrap(); + assert!(!can_combine_variants(doc, &[c, d]), "variants of one set"); + assert!(!can_combine_variants(doc, &[c, a]), "a variant and a lone component"); + assert!(!can_combine_variants(doc, &[set, a]), "a set isn't a component"); + + // Components in different frames still combine. + let holder = idv(&ex(&mut s, "frame.create", json!({"x": 0, "y": 600, "width": 300, "height": 100}))); + let e = component(&mut s, "E", 0.0); + ex(&mut s, "node.reparent", json!({"ids": [e], "parent": holder})); + assert!(can_combine_variants(s.doc().unwrap(), &[a, e])); +} + +#[test] +fn add_variant_is_offered_for_sets_variants_and_lone_components() { + let mut s = Session::new(); + let a = component(&mut s, "A", 0.0); + let b = component(&mut s, "B", 200.0); + let lone = component(&mut s, "Lone", 400.0); + let set = idv(&ex(&mut s, "component.combineVariants", json!({"ids": [a, b]}))); + let frame = idv(&ex(&mut s, "frame.create", json!({"x": 0, "y": 300, "width": 50, "height": 50}))); + let inst = idv(&ex(&mut s, "instance.create", json!({"component": lone, "x": 0, "y": 400}))); + let doc = s.doc().unwrap(); + assert!(can_add_variant(doc, set)); + assert!(can_add_variant(doc, a)); + assert!(can_add_variant(doc, lone)); + assert!(!can_add_variant(doc, frame)); + assert!(!can_add_variant(doc, inst)); + assert!(!can_add_variant(doc, kids(&s, inst)[0]), "a layer inside an instance"); + assert!(!can_add_variant(doc, NodeId(999_999))); +} + +#[test] +fn combining_variants_again_is_refused_instead_of_nesting_sets() { + let mut s = Session::new(); + let a = component(&mut s, "State=Default", 0.0); + let b = component(&mut s, "State=Hover", 200.0); + let lone = component(&mut s, "State=Pressed", 400.0); + let set = idv(&ex(&mut s, "component.combineVariants", json!({"ids": [a, b]}))); + let before = s.doc().unwrap().clone(); + let steps = undo_len(&s); + for ids in [vec![a, b], vec![a, lone]] { + let err = s.execute("component.combineVariants", &json!({"ids": ids})).unwrap_err(); + assert!(err.to_string().contains("variants already"), "{err}"); + } + assert_eq!(*s.doc().unwrap(), before, "a refused combine changes nothing"); + assert_eq!(undo_len(&s), steps); + assert_eq!(sets(&s), 1); + assert_eq!(kids(&s, set), vec![a, b]); + // One lone component still makes a set of one (other commands rely on it). + ex(&mut s, "component.combineVariants", json!({"ids": [lone]})); + assert_eq!(sets(&s), 2); + healthy(&s); +} + +#[test] +fn combining_refuses_nested_components_and_components_inside_instances() { + let mut s = Session::new(); + // A "Card" component holding an "Icon" component. + let card = idv(&ex(&mut s, "frame.create", json!({"x": 0, "y": 0, "width": 200, "height": 100, "name": "Card"}))); + let icon = idv(&ex(&mut s, "frame.create", json!({"x": 10, "y": 10, "width": 20, "height": 20, "name": "Icon", "parent": card}))); + let icon = idv(&ex(&mut s, "component.create", json!({"ids": [icon]}))); + let card = idv(&ex(&mut s, "component.create", json!({"ids": [card]}))); + let other = component(&mut s, "Other", 300.0); + let inst = idv(&ex(&mut s, "instance.create", json!({"component": card, "x": 0, "y": 300}))); + let inner = kids(&s, inst).into_iter().find(|c| matches!(node(&s, *c).kind, NodeKind::Component { .. })).expect("the instance's copy of Icon"); + + let doc = s.doc().unwrap(); + assert!(!can_combine_variants(doc, &[card, icon]), "a component and one inside it"); + assert!(can_combine_variants(doc, &[icon, other]), "a nested component with another one is fine"); + assert!(!can_combine_variants(doc, &[inner, other]), "a component inside an instance"); + let before = s.doc().unwrap().clone(); + let err = s.execute("component.combineVariants", &json!({"ids": [card, icon]})).unwrap_err(); + assert!(err.to_string().contains("inside it"), "{err}"); + assert_eq!(*s.doc().unwrap(), before); +} + +#[test] +fn add_variant_on_a_lone_component_makes_it_a_set_in_one_step() { + let mut s = Session::new(); + let c = component(&mut s, "Button", 0.0); + let inst = idv(&ex(&mut s, "instance.create", json!({"component": c, "x": 0, "y": 300}))); + let before = s.doc().unwrap().clone(); + let steps = undo_len(&s); + + let v = idv(&ex(&mut s, "component.addVariant", json!({"id": c}))); + let set = node(&s, c).parent.unwrap(); + let set_node = node(&s, set); + assert!(matches!(set_node.kind, NodeKind::ComponentSet { .. })); + assert_eq!(set_node.name, "Button", "the set takes the component's name"); + assert_eq!(kids(&s, set), vec![c, v]); + assert_eq!(node(&s, c).name, "Property 1=Default"); + assert_eq!(node(&s, v).name, "Property 1=Variant2"); + assert_eq!(s.selection(), vec![v]); + assert!(set_node.height >= 40.0 * 2.0 + 20.0, "the set wraps both variants: {}", set_node.height); + assert!(matches!(node(&s, inst).kind, NodeKind::Instance { component, .. } if component == c), "instances keep their main"); + assert_eq!(undo_len(&s), steps + 1, "wrapping and adding are one undo step"); + healthy(&s); + + s.undo().unwrap(); + assert_eq!(*s.doc().unwrap(), before, "undo puts the lone component back"); +} + +#[test] +fn add_variant_on_a_lone_component_keeps_prop_value_names_and_rolls_back_on_errors() { + let mut s = Session::new(); + let c = component(&mut s, "Size=Small", 0.0); + let before = s.doc().unwrap().clone(); + let steps = undo_len(&s); + assert!(s.execute("component.addVariant", &json!({"id": c, "property": "Nope", "value": "x"})).is_err()); + assert_eq!(*s.doc().unwrap(), before, "a refused Add variant leaves no set behind"); + assert_eq!(undo_len(&s), steps); + + let v = idv(&ex(&mut s, "component.addVariant", json!({"id": c, "property": "Size", "value": "Large"}))); + assert_eq!(node(&s, c).name, "Size=Small"); + assert_eq!(node(&s, v).name, "Size=Large"); + healthy(&s); +} + +#[test] +fn add_variant_on_a_lone_component_in_auto_layout_keeps_its_place() { + let mut s = Session::new(); + let row = idv(&ex(&mut s, "frame.create", json!({"x": 0, "y": 0, "width": 400, "height": 100, "name": "Row"}))); + ex(&mut s, "layout.add", json!({"ids": [row]})); + let first = idv(&ex(&mut s, "shape.rectangle", json!({"x": 0, "y": 0, "width": 40, "height": 40, "parent": row}))); + let c = component(&mut s, "Chip", 0.0); + ex(&mut s, "node.reparent", json!({"ids": [c], "parent": row})); + let last = idv(&ex(&mut s, "shape.rectangle", json!({"x": 0, "y": 0, "width": 40, "height": 40, "parent": row}))); + + ex(&mut s, "component.addVariant", json!({"id": c})); + let set = node(&s, c).parent.unwrap(); + assert_eq!(kids(&s, row), vec![first, set, last], "the set takes the component's place in the row"); + assert_eq!(kids(&s, set).len(), 2); + healthy(&s); +} diff --git a/crates/engine/src/vector_behaviour_tests.rs b/crates/engine/src/vector_behaviour_tests.rs index 8899dc4e..fb734085 100644 --- a/crates/engine/src/vector_behaviour_tests.rs +++ b/crates/engine/src/vector_behaviour_tests.rs @@ -439,3 +439,61 @@ fn bend_paint_bucket_cut_join_and_eraser_tools() { assert_eq!(net(&s, id).segments.len(), 2, "the eraser removes what it passes over"); assert_eq!(s.doc().unwrap().node(page).unwrap().children, vec![id], "and the pieces stay one layer"); } + +#[test] +fn vector_paths_take_the_requested_size_and_refuse_empty_geometry() { + let mut s = Session::new(); + let path = "M0 0 L24 0 L24 24 L0 24 Z"; + let intrinsic = id_of(&ex(&mut s, "vector.create", json!({"path": path}))["id"]).unwrap(); + let n = s.doc().unwrap().node(intrinsic).unwrap(); + assert_eq!((n.width, n.height), (24.0, 24.0), "a path without a size keeps its intrinsic box"); + // Both sides: each scales independently, like resizing a vector. + let both = id_of(&ex(&mut s, "vector.create", json!({"path": path, "width": 16, "height": 8}))["id"]).unwrap(); + let n = s.doc().unwrap().node(both).unwrap(); + assert_eq!((n.width, n.height), (16.0, 8.0)); + let n = net(&s, both); + let max = n.vertices.iter().fold((0.0f64, 0.0f64), |(x, y), v| (x.max(v.x), y.max(v.y))); + assert!((max.0 - 16.0).abs() < 1e-9 && (max.1 - 8.0).abs() < 1e-9, "{max:?}: the geometry itself is scaled"); + // One side: the other keeps its proportion. + let width = id_of(&ex(&mut s, "vector.create", json!({"path": path, "width": 16}))["id"]).unwrap(); + let n = s.doc().unwrap().node(width).unwrap(); + assert_eq!((n.width, n.height), (16.0, 16.0)); + // A path or point set that draws nothing is an error, not an invisible zero-size layer. + let e = s.execute("vector.create", &json!({"path": "M5 5"})).unwrap_err().to_string(); + assert!(e.contains("draws nothing"), "{e}"); + let e = s.execute("vector.create", &json!({"points": [[5, 5], [5, 5]]})).unwrap_err().to_string(); + assert!(e.contains("draws nothing"), "{e}"); + // A size on an axis the geometry has none of is refused, not silently ignored. + let e = s.execute("vector.create", &json!({"path": "M0 0 L0 24", "width": 16})).unwrap_err().to_string(); + assert!(e.contains("horizontal extent"), "{e}"); + let e = s.execute("vector.create", &json!({"path": "M0 0 L24 0", "height": 16})).unwrap_err().to_string(); + assert!(e.contains("vertical extent"), "{e}"); + // But 0 on an axis it has none of is what it already is: a horizontal line with height 0. + let line = id_of(&ex(&mut s, "vector.create", json!({"path": "M0 0 L24 0", "width": 16, "height": 0}))["id"]).unwrap(); + let n = s.doc().unwrap().node(line).unwrap(); + assert_eq!((n.width, n.height), (16.0, 0.0)); + // A size of 0 or less on an axis it has would flatten it, so it is refused, alone or with the other. + for p in [json!({"path": path, "width": 0}), json!({"path": path, "width": 16, "height": 0}), json!({"path": path, "width": -4, "height": 8})] { + let e = s.execute("vector.create", &p).unwrap_err().to_string(); + assert!(e.contains("positive"), "{p}: {e}"); + } +} + +#[test] +fn vector_points_take_the_requested_size() { + let mut s = Session::new(); + let points = json!([[10, 20], [34, 20], [34, 32]]); + let corners = |s: &Session, id: NodeId| net(s, id).vertices.iter().map(|v| (v.x, v.y)).collect::>(); + // Both sides: each axis scales on its own; the vertices move with the box. + let both = id_of(&ex(&mut s, "vector.create", json!({"points": points, "x": 10, "y": 20, "width": 48, "height": 6}))["id"]).unwrap(); + let n = s.doc().unwrap().node(both).unwrap(); + assert_eq!((n.width, n.height), (48.0, 6.0)); + assert_eq!(corners(&s, both), vec![(0.0, 0.0), (48.0, 0.0), (48.0, 6.0)]); + // One side: the other keeps its proportion. + let one = id_of(&ex(&mut s, "vector.create", json!({"points": points, "height": 24}))["id"]).unwrap(); + let n = s.doc().unwrap().node(one).unwrap(); + assert_eq!((n.width, n.height), (48.0, 24.0)); + assert_eq!(corners(&s, one), vec![(0.0, 0.0), (48.0, 0.0), (48.0, 24.0)]); + let e = s.execute("vector.create", &json!({"points": points, "width": 16, "height": -1})).unwrap_err().to_string(); + assert!(e.contains("positive"), "{e}"); +} 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..ed13ed70 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 }); } @@ -1010,6 +1010,10 @@ impl Session { set_state(self, |e| { e.vertices.clear(); e.segments.clear(); + for o in &mut e.others { + o.vertices.clear(); + o.segments.clear(); + } })?; } } 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..b48dd9d3 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: false }, + ]; 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..2d02539e 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,20 @@ 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, + } +} + fn guid(session: u32, local: u32) -> Kv<'static> { o(vec![("sessionID", u(session)), ("localID", u(local))]) } @@ -1415,8 +1434,32 @@ impl<'d> Exporter<'d> { )]) }; 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"); + // Sizing is 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). + let mut dropped = false; + let sizing = |tracks: &[GridTrack], ids: &[u32], dropped: &mut bool| -> Option> { + let mut entries = Vec::new(); + for (i, id) in ids.iter().enumerate() { + let Some(track) = tracks.get(i) else { continue }; + 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))])) + }; + let column_sizing = sizing(&l.column_tracks, &cols, &mut dropped); + let row_sizing = 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)); } else { diff --git a/crates/format/src/fig/export_tests.rs b/crates/format/src/fig/export_tests.rs index 0eac9571..ae7da3ed 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: false } } fn effect(r: &mut Lcg) -> Effect { @@ -1022,6 +1022,45 @@ 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"); +} + /// 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 7df4d9e2..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. @@ -368,6 +368,9 @@ struct Mapper<'a> { blobs: Vec<&'a [u8]>, /// Component property definitions: id → (REST name "Name#id", type). prop_defs: BTreeMap, + /// The copies of a set's definitions each variant keeps (no name or type of their own; the + /// variant's layers and its instances' assignments use them): copy id → the definition's id. + prop_parents: BTreeMap, /// File nodes by asset `key` and `version`: references can name a collection, variable or /// style as the asset instead of the guid, and the file carries the resources to resolve /// them (the collection node itself says the same `key`). @@ -402,6 +405,7 @@ impl<'a> Mapper<'a> { let mut nodes = BTreeMap::new(); let mut kids: BTreeMap> = BTreeMap::new(); let mut prop_defs = BTreeMap::new(); + let mut prop_parents = BTreeMap::new(); let changes = msg.get("nodeChanges").map(Kv::list).unwrap_or_default(); // The last change of a GUID wins; a REMOVED change deletes what came before it. for n in changes { @@ -423,10 +427,11 @@ impl<'a> Mapper<'a> { kids.entry(parent).or_default().push((p.get("position").and_then(Kv::str).unwrap_or(""), id.clone())); } for d in n.get("componentPropDefs").map(Kv::list).unwrap_or_default() { - if let (Some(did), Some(name), Some(ty)) = - (d.get("id").and_then(guid), d.get("name").and_then(Kv::str), d.get("type").and_then(Kv::str)) - { + let Some(did) = d.get("id").and_then(guid) else { continue }; + if let (Some(name), Some(ty)) = (d.get("name").and_then(Kv::str), d.get("type").and_then(Kv::str)) { prop_defs.insert(did.clone(), (format!("{name}#{did}"), ty)); + } else if let Some(parent) = d.get("parentPropDefId").and_then(guid) { + prop_parents.insert(did, parent); } } } @@ -450,6 +455,7 @@ impl<'a> Mapper<'a> { kids, blobs, prop_defs, + prop_parents, assets, styles: Obj::new(), components: Obj::new(), @@ -640,7 +646,7 @@ impl<'a> Mapper<'a> { targets.extend(data.and_then(|d| d.get("symbolID")).and_then(guid)); for assignment in row.get("componentPropAssignments").map(Kv::list).unwrap_or_default() { if assignment.get("defID").is_some_and(|def| self.is_instance_swap(def).is_some()) { - targets.extend(assignment.get("value").and_then(|v| v.get("guidValue")).and_then(guid)); + targets.extend(assigned(assignment).and_then(prop_guid)); } } let overrides = data.and_then(|d| d.get("symbolOverrides")).map(Kv::list).unwrap_or_default(); @@ -651,7 +657,31 @@ impl<'a> Mapper<'a> { } fn is_instance_swap(&self, def: &Kv) -> Option<()> { - matches!(self.prop_defs.get(&guid(def)?), Some((_, "INSTANCE_SWAP"))).then_some(()) + matches!(self.prop_def(&guid(def)?), Some((_, "INSTANCE_SWAP"))).then_some(()) + } + + /// The definition a copy of it was made from, followed to the original: the values an instance + /// sets are kept under it, since an assignment and the layers that read it may name different + /// copies of one definition. + fn prop_root(&self, def: &str) -> String { + let mut def = def; + for _ in 0..=MAX_DEPTH { + let Some(parent) = self.prop_parents.get(def) else { break }; + def = parent; + } + def.to_string() + } + + /// The definition `def` names: its own, or the set's for a variant's copy of it. + fn prop_def(&self, def: &str) -> Option<&(String, &'a str)> { + let mut def = def; + for _ in 0..=MAX_DEPTH { + if let Some(found) = self.prop_defs.get(def) { + return Some(found); + } + def = self.prop_parents.get(def)?; + } + None } /// The branches of the internal canvas `id` that hold the `required_mains` (with the frames @@ -908,7 +938,7 @@ impl<'a> Mapper<'a> { } self.prop_refs(&mut o, &view, inst); let slot_content = if own == "FRAME" { - prop_ref(&view, "SLOT_CONTENT_ID").and_then(|def| inst.and_then(|i| i.slot_contents.get(&def).cloned())) + prop_ref(&view, "SLOT_CONTENT_ID").and_then(|def| inst.and_then(|i| i.slot_contents.get(&self.prop_root(&def)).cloned())) } else { None }; @@ -1892,11 +1922,11 @@ impl<'a> Mapper<'a> { // Expanded frames must follow their main frame's source link. A direct slot reference // makes the engine resolve ownership against the instance, where the property is not defined. if !(field == "slot" && inst.is_some()) - && let Some((name, _)) = self.prop_defs.get(&def) + && let Some((name, _)) = self.prop_def(&def) { refs.insert(field.into(), json!(name)); } - let Some(val) = inst.and_then(|i| i.props.get(&def)) else { continue }; + let Some(val) = inst.and_then(|i| i.props.get(&self.prop_root(&def))) else { continue }; match field { "visible" => { if let Some(b) = val.get("boolValue").and_then(Kv::bool) { @@ -1911,7 +1941,7 @@ impl<'a> Mapper<'a> { if own_text(v) != Some(t) { o.remove("characterStyleOverrides"); o.remove("styleOverrideTable"); - if let Some(tv) = val.get("textValue") { + if let Some(tv) = prop_text_data(val) { let size = o.get("style").and_then(|s| s.get("fontSize")).and_then(Value::as_f64); char_styles(o, tv, size, &mut |w| self.warn(w)); } @@ -1933,7 +1963,7 @@ impl<'a> Mapper<'a> { // An instance swap property value picks the main. if let Some(i) = outer && let Some(def) = prop_ref(v, "OVERRIDDEN_SYMBOL_ID") - && let Some(g) = i.props.get(&def).and_then(|p| p.get("guidValue")).and_then(guid) + && let Some(g) = i.props.get(&self.prop_root(&def)).and_then(|p| prop_guid(p)) { sym = Some(g); } @@ -1945,12 +1975,12 @@ impl<'a> Mapper<'a> { let assignments = v.list("componentPropAssignments"); let mut props = Obj::new(); for a in assignments { - let Some((name, ty)) = a.get("defID").and_then(guid).and_then(|d| self.prop_defs.get(&d).cloned()) else { continue }; - let val = a.get("value"); + let Some((name, ty)) = a.get("defID").and_then(guid).and_then(|d| self.prop_def(&d).cloned()) else { continue }; + let val = assigned(a); let p = match ty { "BOOL" => val.and_then(|x| x.get("boolValue")).and_then(Kv::bool).map(|b| json!({"type": "BOOLEAN", "value": b})), "TEXT" => val.and_then(prop_text).map(|t| json!({"type": "TEXT", "value": t})), - "INSTANCE_SWAP" => val.and_then(|x| x.get("guidValue")).and_then(guid).map(|g| json!({"type": "INSTANCE_SWAP", "value": g})), + "INSTANCE_SWAP" => val.and_then(prop_guid).map(|g| json!({"type": "INSTANCE_SWAP", "value": g})), _ => None, }; if let Some(p) = p { @@ -2027,11 +2057,11 @@ impl<'a> Mapper<'a> { let mut all_props: BTreeMap> = outer.map(|i| (*i.props).clone()).unwrap_or_default(); let mut slot_contents: BTreeMap = outer.map(|i| (*i.slot_contents).clone()).unwrap_or_default(); for a in assignments { - if let (Some(d), Some(val)) = (a.get("defID").and_then(guid), a.get("value")) { - all_props.entry(d).or_insert(val); + if let (Some(d), Some(val)) = (a.get("defID").and_then(guid), assigned(a)) { + all_props.entry(self.prop_root(&d)).or_insert(val); } let Some(def) = a.get("defID").and_then(guid) else { continue }; - if self.prop_defs.get(&def).is_none_or(|(_, ty)| *ty != "SLOT") { + if self.prop_def(&def).is_none_or(|(_, ty)| *ty != "SLOT") { continue; } let var_value = a.get("varValue"); @@ -2052,7 +2082,7 @@ impl<'a> Mapper<'a> { self.warn("slot content assignment target is missing or is not slot content"); continue; } - slot_contents.entry(def).or_insert(target); + slot_contents.entry(self.prop_root(&def)).or_insert(target); } let mut symbols = outer.map(|i| i.symbols.clone()).unwrap_or_default(); symbols.push(sym.clone()); @@ -2777,7 +2807,23 @@ fn char_styles(o: &mut Obj, td: &Kv, size: Option, warn: &mut dyn FnMut(Str /// The text of a component property value. fn prop_text<'k>(v: &'k Kv) -> Option<&'k str> { - v.get("textValue").and_then(|t| t.get("characters")).and_then(Kv::str) + prop_text_data(v).and_then(|t| t.get("characters")).and_then(Kv::str) +} + +/// The text data of a component property value: `textValue`, or `textDataValue` in the variable form. +fn prop_text_data<'k, 's>(v: &'k Kv<'s>) -> Option<&'k Kv<'s>> { + v.get("textValue").or_else(|| v.get("textDataValue")) +} + +/// The main an instance swap value names: `guidValue`, or `symbolIdValue` in the variable form. +fn prop_guid(v: &Kv) -> Option { + v.get("guidValue").and_then(guid).or_else(|| v.get("symbolIdValue")?.get("guid").and_then(guid)) +} + +/// The value a component property assignment sets: `value`, or the variable form `varValue` holds +/// when `value` is empty, as current files write many booleans and instance swaps. +fn assigned<'k, 's>(assignment: &'k Kv<'s>) -> Option<&'k Kv<'s>> { + assignment.get("value").filter(|v| !matches!(v, Kv::Object(fields) if fields.is_empty())).or_else(|| assignment.get("varValue")?.get("value")) } /// The property a layer's `field` is bound to (`parameterConsumptionMap`). diff --git a/crates/format/src/fig/mod.rs b/crates/format/src/fig/mod.rs index fc9293d9..4c103d08 100644 --- a/crates/format/src/fig/mod.rs +++ b/crates/format/src/fig/mod.rs @@ -26,6 +26,8 @@ pub mod kiwi; mod map; mod network; #[cfg(test)] +mod prop_value_tests; +#[cfg(test)] mod slot_tests; #[cfg(test)] mod source_identity_tests; diff --git a/crates/format/src/fig/prop_value_tests.rs b/crates/format/src/fig/prop_value_tests.rs new file mode 100644 index 00000000..24248d32 --- /dev/null +++ b/crates/format/src/fig/prop_value_tests.rs @@ -0,0 +1,188 @@ +//! Component properties as current `.fig` files write them, on synthetic messages: the copy of a +//! set's definitions each variant keeps (`parentPropDefId`, no name or type of its own), and +//! assignments that carry their value only in the variable form (`varValue`). + +use figcraft_doc::NodeKind; +use serde_json::Value; + +use super::kiwi::Kv; +use super::map::to_rest; + +fn g(local: u32) -> Kv<'static> { + Kv::Object(vec![("sessionID", Kv::Uint(9)), ("localID", Kv::Uint(u64::from(local)))]) +} + +fn o(fields: Vec<(&'static str, Kv<'static>)>) -> Kv<'static> { + Kv::Object(fields) +} + +fn s(text: &str) -> Kv<'static> { + Kv::Str(text.into()) +} + +fn node(local: u32, kind: &'static str, name: &str, parent: Option, mut fields: Vec<(&'static str, Kv<'static>)>) -> Kv<'static> { + let mut all = vec![("guid", g(local)), ("type", Kv::Enum(kind)), ("name", s(name))]; + if let Some(parent) = parent { + all.push(("parentIndex", o(vec![("guid", g(parent)), ("position", s("!"))]))); + } + all.append(&mut fields); + o(all) +} + +/// A layer's `field` bound to property definition `def`. +fn bound(field: &'static str, def: u32) -> (&'static str, Kv<'static>) { + let entry = o(vec![ + ("variableField", Kv::Enum(field)), + ("variableData", o(vec![("value", o(vec![("propRefValue", o(vec![("defId", g(def))]))])), ("dataType", Kv::Enum("PROP_REF"))])), + ]); + ("parameterConsumptionMap", o(vec![("entries", Kv::Array(vec![entry]))])) +} + +fn definition(id: u32, name: &str, kind: &'static str, initial: Kv<'static>) -> Kv<'static> { + o(vec![("id", g(id)), ("name", s(name)), ("type", Kv::Enum(kind)), ("initialValue", initial)]) +} + +/// An assignment of `def` that leaves `value` empty and says it in `varValue` only. +fn variable_assignment(def: u32, data_type: &'static str, value: Kv<'static>) -> Kv<'static> { + o(vec![ + ("defID", g(def)), + ("value", o(vec![])), + ("varValue", o(vec![("value", value), ("dataType", Kv::Enum(data_type)), ("resolvedDataType", Kv::Enum(data_type))])), + ]) +} + +fn message(nodes: Vec>) -> Kv<'static> { + let mut all = vec![node(1, "DOCUMENT", "Document", None, vec![]), node(2, "CANVAS", "Page", Some(1), vec![])]; + all.extend(nodes); + o(vec![("nodeChanges", Kv::Array(all))]) +} + +fn by_id<'v>(v: &'v Value, id: &str) -> &'v Value { + fn find<'v>(v: &'v Value, id: &str) -> Option<&'v Value> { + if v.get("id").and_then(Value::as_str) == Some(id) { + return Some(v); + } + v.get("children").and_then(Value::as_array).into_iter().flatten().find_map(|v| find(v, id)) + } + find(v, id).unwrap_or_else(|| panic!("no layer {id}")) +} + +fn instance_props(doc: &figcraft_doc::Document) -> &std::collections::BTreeMap { + let instance = doc.ids().filter_map(|id| doc.node(id)).find(|n| n.name == "Instance").unwrap(); + let NodeKind::Instance { props, .. } = &instance.kind else { panic!("not an instance") }; + props +} + +/// A "Field" set with a BOOL definition (9:70), a variant whose "Hint text" reads its copy (9:71), +/// and an instance of the variant that sets the property false through definition `assigned`. +fn field_set(assigned: u32) -> Kv<'static> { + message(vec![ + node( + 9, + "FRAME", + "Field", + Some(2), + vec![ + ("isStateGroup", Kv::Bool(true)), + ("componentPropDefs", Kv::Array(vec![definition(70, "Hint", "BOOL", o(vec![("boolValue", Kv::Bool(true))]))])), + ], + ), + node( + 10, + "SYMBOL", + "State=Default", + Some(9), + vec![("componentPropDefs", Kv::Array(vec![o(vec![("id", g(71)), ("parentPropDefId", g(70))])]))], + ), + node(11, "TEXT", "Hint text", Some(10), vec![bound("VISIBLE", 71)]), + node( + 20, + "INSTANCE", + "Instance", + Some(2), + vec![ + ("symbolData", o(vec![("symbolID", g(10))])), + ("componentPropAssignments", Kv::Array(vec![o(vec![("defID", g(assigned)), ("value", o(vec![("boolValue", Kv::Bool(false))]))])])), + ], + ), + ]) +} + +#[test] +fn variant_layers_bind_to_the_set_properties_their_copies_name() { + let msg = field_set(71); + let (rest, _, _) = to_rest(&msg, "Synthetic", crate::rest::MAX_NODES); + assert_eq!(by_id(&rest["document"], "9:9")["componentPropertyDefinitions"]["Hint#9:70"]["type"], "BOOLEAN"); + assert_eq!(by_id(&rest["document"], "9:11")["componentPropertyReferences"]["visible"], "Hint#9:70"); + assert_eq!(by_id(&rest["document"], "9:20")["componentProperties"]["Hint#9:70"]["value"], false); + + let (doc, _) = crate::rest::import_fig(&rest, &crate::rest::no_images).unwrap(); + let hint = doc.ids().filter_map(|id| doc.node(id)).find(|n| n.name == "Hint text" && n.source.is_none()).unwrap(); + assert_eq!(hint.prop_refs.get("visible").map(String::as_str), Some("Hint")); + assert_eq!(instance_props(&doc).get("Hint"), Some(&Value::Bool(false))); +} + +/// An assignment reaches layers that read another copy of its definition: here the instance sets +/// the set's own definition while the variant's layer reads the variant's copy. +#[test] +fn assignments_reach_layers_that_read_another_copy_of_their_definition() { + let (rest, _, _) = to_rest(&field_set(70), "Synthetic", crate::rest::MAX_NODES); + let instance = by_id(&rest["document"], "9:20"); + assert_eq!(instance["componentProperties"]["Hint#9:70"]["value"], false); + assert_eq!(by_id(instance, "I9:20;9:11")["visible"], false); +} + +#[test] +fn property_values_written_only_as_variables_are_applied() { + let msg = message(vec![ + node( + 10, + "SYMBOL", + "Field", + Some(2), + vec![( + "componentPropDefs", + Kv::Array(vec![ + definition(70, "Hint", "BOOL", o(vec![("boolValue", Kv::Bool(true))])), + definition(71, "Label", "TEXT", o(vec![("textValue", o(vec![("characters", s("Label"))]))])), + definition(72, "Icon", "INSTANCE_SWAP", o(vec![("guidValue", g(40))])), + ]), + )], + ), + node(11, "TEXT", "Hint text", Some(10), vec![bound("VISIBLE", 70)]), + node(12, "TEXT", "Label", Some(10), vec![("textData", o(vec![("characters", s("Label"))])), bound("TEXT_DATA", 71)]), + node(13, "INSTANCE", "Icon", Some(10), vec![("symbolData", o(vec![("symbolID", g(40))])), bound("OVERRIDDEN_SYMBOL_ID", 72)]), + node(40, "SYMBOL", "Mail", Some(2), vec![]), + node(41, "SYMBOL", "Card", Some(2), vec![]), + node( + 20, + "INSTANCE", + "Instance", + Some(2), + vec![ + ("symbolData", o(vec![("symbolID", g(10))])), + ( + "componentPropAssignments", + Kv::Array(vec![ + variable_assignment(70, "BOOLEAN", o(vec![("boolValue", Kv::Bool(false))])), + variable_assignment(71, "TEXT_DATA", o(vec![("textDataValue", o(vec![("characters", s("Name on card"))]))])), + variable_assignment(72, "SYMBOL_ID", o(vec![("symbolIdValue", o(vec![("guid", g(41))]))])), + ]), + ), + ], + ), + ]); + let (rest, _, _) = to_rest(&msg, "Synthetic", crate::rest::MAX_NODES); + let instance = by_id(&rest["document"], "9:20"); + assert_eq!(instance["componentProperties"]["Hint#9:70"]["value"], false); + assert_eq!(instance["componentProperties"]["Label#9:71"]["value"], "Name on card"); + assert_eq!(instance["componentProperties"]["Icon#9:72"]["value"], "9:41"); + assert_eq!(by_id(instance, "I9:20;9:11")["visible"], false); + assert_eq!(by_id(instance, "I9:20;9:12")["characters"], "Name on card"); + assert_eq!(by_id(instance, "I9:20;9:13")["componentId"], "9:41"); + + let (doc, _) = crate::rest::import_fig(&rest, &crate::rest::no_images).unwrap(); + let props = instance_props(&doc); + assert_eq!(props.get("Hint"), Some(&Value::Bool(false))); + assert_eq!(props.get("Label"), Some(&Value::String("Name on card".into()))); +} diff --git a/crates/format/src/rest/props.rs b/crates/format/src/rest/props.rs index efb7710d..ff06c737 100644 --- a/crates/format/src/rest/props.rs +++ b/crates/format/src/rest/props.rs @@ -139,7 +139,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: false }) } /// Gradient handles are already in the node's unit box (start, end, width), like ours. 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/lib.rs b/crates/geom/src/lib.rs index da351e5b..e0a4dde0 100644 --- a/crates/geom/src/lib.rs +++ b/crates/geom/src/lib.rs @@ -284,7 +284,9 @@ impl ReverseSubpaths for BezPath { kurbo::PathSeg::Cubic(c) => out.curve_to(c.p1, c.p2, c.p3), } } - out.close_path(); + if matches!(seg_path.elements().last(), Some(kurbo::PathEl::ClosePath)) { + out.close_path(); + } } out } diff --git a/crates/geom/src/tests.rs b/crates/geom/src/tests.rs index b99d7d8c..15e24d49 100644 --- a/crates/geom/src/tests.rs +++ b/crates/geom/src/tests.rs @@ -108,3 +108,18 @@ fn set_rotation_keeps_center() { } } } + +#[test] +fn reverse_subpaths_preserves_open_and_closed() { + let mut open = BezPath::new(); + open.move_to((0.0, 0.0)); + open.line_to((10.0, 0.0)); + open.line_to((10.0, 10.0)); + let r = ::reverse_subpaths(&open); + assert!(!r.elements().iter().any(|e| matches!(e, kurbo::PathEl::ClosePath)), "{:?}", r.elements()); + + let mut closed = open.clone(); + closed.close_path(); + let r = ::reverse_subpaths(&closed); + assert!(matches!(r.elements().last(), Some(kurbo::PathEl::ClosePath)), "{:?}", r.elements()); +} diff --git a/crates/mcp/src/tools.rs b/crates/mcp/src/tools.rs index 79ef4945..7714da5a 100644 --- a/crates/mcp/src/tools.rs +++ b/crates/mcp/src/tools.rs @@ -147,7 +147,7 @@ pub fn tool_definitions() -> Vec { "type": {"type": "string", "enum": ["frame", "rectangle", "ellipse", "polygon", "star", "line", "arrow", "text", "vector", "section", "component", "slice"], "description": "Layer type"}, "x": num("Left, relative to the parent frame (default 0)"), "y": num("Top, relative to the parent frame (default 0)"), - "width": num("Width (default 100; text hugs its content unless width is set, which makes it wrap)"), + "width": num("Width (default 100; text hugs its content unless width is set, which makes it wrap; for a vector with path, width/height scale the path, one alone keeping its proportion, and without them the path's own box is used)"), "height": num("Height (default 100; ignored for lines/arrows and auto-height text)"), "parent": id_schema("Parent frame/group/component id (default: the current page)"), "index": int("Z-index among the parent's children (default: on top)"), 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..ca2ee0b6 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,94 @@ 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). 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) }); + elliptical_rect(figcraft_geom::Rect::new(-l * f, -t * f, w + r * f, h + b * f), grown) + }; + 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 the + // outer ones are smoothed: then it may leave the outer edge. + ring(outer, &elliptical_rect(inner, inset), fit < 1.0 || (align == StrokeAlign::Inside && smoothing > 0.0)) +} + +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..ee0e8281 --- /dev/null +++ b/crates/pathops/src/rounded_side_strokes_tests.rs @@ -0,0 +1,114 @@ +//! 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 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/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..521146fb 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,54 @@ 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 of two or more segments 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; + if open && segments >= 2 { + 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 1433b942..38abc14e 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,16 @@ 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| !matches!(c, '\u{0}'..='\u{8}' | '\u{b}' | '\u{c}' | '\u{e}'..='\u{1f}' | '\u{fffe}' | '\u{ffff}')) + .collect::() + .replace('&', "&") + .replace('<', "<") + .replace('>', ">") + .replace('"', """) } fn hex(c: Color) -> String { @@ -160,8 +170,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. @@ -549,7 +564,15 @@ impl W<'_> { 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(); 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 } @@ -586,7 +609,8 @@ impl W<'_> { } let end = glyphs.get(j).map_or(line.char_end, |g| g.char_start).max(start).min(chars.len()); let text: String = chars.get(start.min(end)..end).unwrap_or(&[]).iter().collect(); - let text = st.case.apply(text.trim_end_matches(['\n', '\r'])); + let prev = start.checked_sub(1).and_then(|k| chars.get(k)).copied(); + let text = st.case.apply_after(text.trim_end_matches(['\n', '\r']), prev); let own = match layout.styles.get(style).and_then(|s| s.fills.as_ref()) { Some(fills) => { let a = fills.iter().find_map(|p| self.paint_attr(p, n.width, n.height)).unwrap_or_else(|| "fill=\"none\"".into()); @@ -601,7 +625,8 @@ impl W<'_> { continue; } let text: String = chars.get(line.char_start..line.char_end.min(chars.len())).unwrap_or(&[]).iter().collect(); - let text = st.case.apply(text.trim_end_matches(['\n', '\r'])); + let prev = line.char_start.checked_sub(1).and_then(|k| chars.get(k)).copied(); + let text = st.case.apply_after(text.trim_end_matches(['\n', '\r']), prev); let (x, y) = layout.glyphs.get(line.glyph_start).map_or((0.0, line.baseline), |g| (g.x, g.y)); let _ = write!(spans, "{}", num(x), num(y), esc(&text)); } @@ -1002,6 +1027,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 5a333d07..3936c37c 100644 --- a/crates/svg/src/tests.rs +++ b/crates/svg/src/tests.rs @@ -116,6 +116,73 @@ fn text_run_fill_survives_live_text_export() { assert!(svg.contains("fill=\"#00FF00\"") && svg.contains(">b") && svg.contains(">a"), "{svg}"); } +#[test] +fn title_case_does_not_restart_words_across_fill_runs_in_live_text() { + use figcraft_doc::{StyleRun, TextCase, TextData}; + figcraft_text::scan_system_fonts(); + let mut d = Document::new("t"); + let page = d.first_page().unwrap(); + let mut text = TextData::new("hello"); + text.style.font_size = 24.0; + text.style.case = TextCase::Title; + let mut style = text.style.clone(); + style.fills = Some(vec![Paint::solid(Color::from_hex("00FF00").unwrap())]); + text.runs.push(StyleRun { start: 2, end: 3, style }); + text.normalize(); + let id = d.create(page, NodeKind::Text(text), "Text", 0.0, 0.0, 200.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(">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(" 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/build.rs b/crates/text/build.rs index d02defef..05dfc653 100644 --- a/crates/text/build.rs +++ b/crates/text/build.rs @@ -65,9 +65,10 @@ fn craft_fonts(dir: &std::path::Path, manifest_rel: &str) -> Result 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{2010}' | '\u{2013}' | '\u{2014}' => Class::Hyphen, '\u{2E80}'..='\u{2FFF}' | '\u{3003}'..='\u{33FF}' @@ -98,3 +98,15 @@ 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!(!is_space('\u{2007}')); + } +} diff --git a/crates/text/src/fontdb.rs b/crates/text/src/fontdb.rs index f9eda2c6..604f3eee 100644 --- a/crates/text/src/fontdb.rs +++ b/crates/text/src/fontdb.rs @@ -444,9 +444,22 @@ pub struct FontDb { families: BTreeMap)>, /// System faces known by name but not loaded (lower-case family → entries). pending: BTreeMap)>, + /// System faces read from disk (file, face index), so a rescan only offers new ones. + loaded_files: std::collections::BTreeSet<(std::path::PathBuf, u32)>, + /// Font files added as bytes (hash and length → families), so loading one again adds nothing. + loaded_bytes: std::collections::HashMap<(u64, usize), Vec>, system_scanned: bool, } +/// What a later walk of the font folders found ([`rescan_system_fonts`]). +#[derive(Clone, Debug, Default, PartialEq, Eq)] +pub struct Rescan { + /// Families that were unknown before. + pub added: Vec, + /// Every family that gained faces: the new ones and those with styles installed since. + pub changed: Vec, +} + static DB: OnceLock> = OnceLock::new(); /// Set by [`scan_system_fonts_on_demand`]. static ON_DEMAND: AtomicBool = AtomicBool::new(false); @@ -580,6 +593,7 @@ impl FontDb { }; if let Some(face) = Face::from_bytes(FontBytes::Shared(bytes), e.index, None, Some(&e.style)) { self.add(face); + self.loaded_files.insert((e.path, e.index)); } } } @@ -670,6 +684,35 @@ impl FontDb { self.faces.len() } + /// Offer the families of a later walk of the font folders: new families become pending, a + /// family not loaded yet takes the walk's files, and a loaded family keeps its faces and gets + /// the files it hasn't read (styles installed since) as pending, read when it is next used. + fn merge_rescan(&mut self, found: BTreeMap)>) -> Rescan { + let mut out = Rescan::default(); + for (k, (display, entries)) in found { + if self.families.contains_key(&k) { + let waiting = self.pending.get(&k).map(|(_, v)| v.clone()).unwrap_or_default(); + let new: Vec = entries + .into_iter() + .filter(|e| { + !self.loaded_files.contains(&(e.path.clone(), e.index)) && !waiting.iter().any(|w| w.path == e.path && w.index == e.index) + }) + .collect(); + if !new.is_empty() { + out.changed.push(display.clone()); + self.pending.entry(k).or_insert_with(|| (display, Vec::new())).1.extend(new); + } + continue; + } + if !self.pending.contains_key(&k) { + out.added.push(display.clone()); + } + out.changed.push(display.clone()); + self.pending.insert(k, (display, entries)); + } + out + } + /// Index the system font directories (desktop only): family names are read now, faces loaded /// when first used. Safe to call more than once. pub fn scan_system(&mut self) { @@ -776,13 +819,19 @@ fn scan_dir(dir: &std::path::Path, depth: usize, out: &mut BTreeMap MAX_FONT_FILE { + continue; + } + let Some(bytes) = usize::try_from(meta.len()).ok().and_then(|l| index_bytes(&path, l)) else { continue }; for (index, family, style) in faces_in(&bytes) { out.entry(family.to_lowercase()).or_insert_with(|| (family.clone(), Vec::new())).1.push(Pending { path: path.clone(), index, style }); } } } +/// The largest font file read (CJK collections run to ~120 MB, Apple Color Emoji ~180 MB). +pub const MAX_FONT_FILE: u64 = 256 * 1024 * 1024; + /// The parts of a font file the scan reads: headers, table directories and `name` tables, at their /// offsets in a zeroed buffer of the file's length (reading whole files was most of the scan). fn index_bytes(path: &std::path::Path, len: usize) -> Option> { @@ -887,6 +936,55 @@ pub fn scan_system_fonts() { }); } +/// Walk the system font folders again, for fonts installed while the app runs (the first scan is +/// once per process): the families that were unknown before, and every family that gained faces. +/// Before the first scan this is that scan; a no-op on the web or with `FIGCRAFT_NO_SYSTEM_FONTS`. +pub fn rescan_system_fonts() -> Rescan { + #[cfg(not(target_arch = "wasm32"))] + if !system_fonts_opted_out() { + if SCANNED.get().is_none() { + let before: std::collections::BTreeSet = + raw_db().read().unwrap_or_else(std::sync::PoisonError::into_inner).families().into_iter().collect(); + scan_system_fonts(); + let added: Vec = family_names().into_iter().filter(|f| !before.contains(f)).collect(); + return Rescan { changed: added.clone(), added }; + } + let mut found: BTreeMap)> = BTreeMap::new(); + for dir in system_font_dirs() { + scan_dir(&dir, 0, &mut found); + } + let rescan = raw_db().write().unwrap_or_else(std::sync::PoisonError::into_inner).merge_rescan(found); + if !rescan.changed.is_empty() { + crate::clear_layout_cache(); + } + return rescan; + } + Rescan::default() +} + +/// Load font file bytes (every face of a collection) into the global db, for a font that isn't +/// installed. Returns the families of its faces (none: not a font file); the same file again +/// returns them without adding its faces twice. +pub fn load_font_bytes(bytes: Vec) -> Vec { + use std::hash::{Hash, Hasher}; + let mut h = std::collections::hash_map::DefaultHasher::new(); + bytes.hash(&mut h); + let key = (h.finish(), bytes.len()); + let mut db = db().write().unwrap_or_else(std::sync::PoisonError::into_inner); + if let Some(families) = db.loaded_bytes.get(&key) { + return families.clone(); + } + let faces = db.add_bytes(bytes); + let mut families: Vec = faces.iter().map(|f| f.family.clone()).collect(); + families.dedup(); + if !families.is_empty() { + db.loaded_bytes.insert(key, families.clone()); + drop(db); + crate::clear_layout_cache(); + } + families +} + /// `FIGCRAFT_NO_SYSTEM_FONTS` is set (and not `0`): renders use only the embedded fonts. #[cfg(not(target_arch = "wasm32"))] fn system_fonts_opted_out() -> bool { diff --git a/crates/text/src/fontdb_tests.rs b/crates/text/src/fontdb_tests.rs index 96ce7842..f2456d28 100644 --- a/crates/text/src/fontdb_tests.rs +++ b/crates/text/src/fontdb_tests.rs @@ -217,3 +217,49 @@ fn user_font_dirs_preserve_non_utf8_paths() { assert_eq!(dirs[0].as_os_str().as_bytes(), b"/home/\xff/.fonts"); assert_eq!(dirs[1].as_os_str().as_bytes(), b"/data/\xfe/fonts"); } + +/// A rescan offers new families and the styles installed since, and never a file twice. +#[test] +fn a_rescan_offers_fonts_installed_since() { + let entry = |s: &str| Pending { path: format!("no-such-dir/{s}.ttf").into(), index: 0, style: s.into() }; + let mut db = pending(&["Regular"]); + let found = |fams: &[(&str, &[&str])]| -> BTreeMap)> { + fams.iter().map(|(f, styles)| (f.to_lowercase(), ((*f).to_string(), styles.iter().map(|s| entry(s)).collect()))).collect() + }; + // A new family is added; a pending one takes the walk's files (a new style); both changed. + let r = db.merge_rescan(found(&[("CJK UI", &["Regular", "Bold"]), ("Brand Sans", &["Regular"])])); + assert_eq!(r.added, ["Brand Sans"]); + assert_eq!(r.changed, ["Brand Sans", "CJK UI"]); + assert!(db.has_family("brand sans")); + assert_eq!(left(&db), ["Regular", "Bold"]); + // A loaded family keeps its faces and gets only the files it hasn't read, once. + db.families.insert("brand sans".into(), ("Brand Sans".into(), Vec::new())); + db.pending.remove("brand sans"); + db.loaded_files.insert(("no-such-dir/Regular.ttf".into(), 0)); + let same = db.merge_rescan(found(&[("Brand Sans", &["Regular"])])); + assert_eq!(same, Rescan::default()); + assert!(!db.pending.contains_key("brand sans")); + let bold = db.merge_rescan(found(&[("Brand Sans", &["Regular", "Black"])])); + assert_eq!((bold.added.len(), bold.changed), (0, vec!["Brand Sans".to_string()])); + let waiting = |db: &FontDb| db.pending.get("brand sans").map(|(_, v)| v.iter().map(|e| e.style.clone()).collect::>()).unwrap_or_default(); + assert_eq!(waiting(&db), ["Black"]); + assert_eq!(db.merge_rescan(found(&[("Brand Sans", &["Regular", "Black"])])), Rescan::default()); + assert_eq!(waiting(&db), ["Black"]); +} + +/// Font file bytes add their faces once; junk adds nothing. +#[test] +fn font_bytes_load_into_the_db() { + assert!(load_font_bytes(b"not a font".to_vec()).is_empty()); + assert!(load_font_bytes(Vec::new()).is_empty()); + // With the embedded fonts (absent on CI builds without craft-fonts): a file's family is added, + // and loading the same file again returns it without adding its faces twice. + if let Some(f) = CRAFT_FONTS.first() { + let families = load_font_bytes(f.bytes.to_vec()); + assert_eq!(families.len(), 1); + assert!(families.iter().all(|f| has_family(f))); + let faces = db().read().unwrap().face_count(); + assert_eq!(load_font_bytes(f.bytes.to_vec()), families); + assert_eq!(db().read().unwrap().face_count(), faces); + } +} diff --git a/crates/text/src/layout.rs b/crates/text/src/layout.rs index 7fc6e11a..f844b571 100644 --- a/crates/text/src/layout.rs +++ b/crates/text/src/layout.rs @@ -82,6 +82,9 @@ pub struct Line { pub x: f64, /// The line ends a paragraph (hard break or end of text). pub para_end: bool, + /// The line ends with a line break char: a newline, or a line separator (U+2028) that breaks + /// the line inside its paragraph. + pub break_after: bool, /// The paragraph runs right to left (its first strong character is right-to-left). pub rtl: bool, } @@ -157,6 +160,15 @@ fn deco_len(m: DecorationLength, size: f64, auto: f64) -> f64 { if v.is_finite() { v } else { auto } } +/// The line separator (U+2028): a line break inside a paragraph, as `.fig` files store a soft +/// break (imported text keeps it; typed text has newlines only). +const LINE_SEPARATOR: char = '\u{2028}'; + +/// A char that ends its line: a newline (which also ends the paragraph) or a line separator. +fn is_line_break(c: char) -> bool { + c == '\n' || c == LINE_SEPARATOR +} + /// Opening punctuation, which hangs outside a line's start. fn opens(c: char) -> bool { matches!(c, '"' | '\'' | '\u{201C}' | '\u{2018}' | '\u{201E}' | '\u{201A}' | '\u{00AB}' | '\u{2039}' | '(' | '[' | '{' | '\u{00BF}' | '\u{00A1}') @@ -332,7 +344,7 @@ pub fn layout(text: &TextData, box_width: Option, box_height: Option) } // Colour emoji are their own, large faces (Apple Color Emoji is ~180 MB): they are read only // for a text with emoji, or with characters nothing above covers. - let covered = |c: char| c == '\n' || faces.iter().flatten().any(|f| f.has_char(c)) || fallbacks.iter().any(|f| f.has_char(c)); + let covered = |c: char| is_line_break(c) || faces.iter().flatten().any(|f| f.has_char(c)) || fallbacks.iter().any(|f| f.has_char(c)); let emoji = if chars.iter().any(|c| is_emoji(*c) || !covered(*c)) { dbw.emoji_fallbacks() } else { Vec::new() }; fallbacks.extend(emoji.iter().cloned()); drop(dbw); @@ -370,7 +382,7 @@ pub fn layout(text: &TextData, box_width: Option, box_height: Option) { return f.clone(); } - if c == '\n' || primary.has_char(c) { + if is_line_break(c) || primary.has_char(c) { return primary.clone(); } let fallbacks = style_fallbacks.get(st_idx).unwrap_or(&fallbacks); @@ -382,13 +394,15 @@ pub fn layout(text: &TextData, box_width: Option, box_height: Option) let mut seg_end = seg_start + 1; while seg_end < *end && chars.get(seg_end).is_some_and(|c| { - *c != '\n' && first != '\n' && (!cluster_start.get(seg_end).copied().unwrap_or(true) || face_for(*c).id == face.id) + !is_line_break(*c) + && !is_line_break(first) + && (!cluster_start.get(seg_end).copied().unwrap_or(true) || face_for(*c).id == face.id) }) && level_at(seg_end) == level { seg_end += 1; } - if first == '\n' { + if is_line_break(first) { items.push(Item { face: face.clone(), coords: no_coords(), @@ -401,7 +415,7 @@ pub fn layout(text: &TextData, box_width: Option, box_height: Option) char_start: seg_start, char_end: seg_start + 1, style: st_idx, - ch: '\n', + ch: first, level, }); } else { @@ -465,6 +479,17 @@ pub fn layout(text: &TextData, box_width: Option, box_height: Option) para += 1; continue; } + // A line separator (a soft break) breaks the line inside its paragraph: no + // paragraph spacing and no new list marker after it. + if it.ch == LINE_SEPARATOR { + lines.push((line_start, i + 1, false, para)); + i += 1; + line_start = i; + x = 0.0; + last_break = None; + para_first_line = false; + continue; + } let line_indent = line_indent_of(para, para_first_line, it.char_start); let w = it.adv; // Hanging punctuation takes no room inside the box. @@ -491,7 +516,8 @@ pub fn layout(text: &TextData, box_width: Option, box_height: Option) } i += 1; } - if line_start < items.len() || lines.is_empty() || lines.last().is_some_and(|l| l.2) { + let break_after = |e: usize| e.checked_sub(1).and_then(|i| items.get(i)).is_some_and(|it| is_line_break(it.ch)); + if line_start < items.len() || lines.is_empty() || lines.last().is_some_and(|l| l.2 || break_after(l.1)) { lines.push((line_start, items.len(), true, para)); } @@ -546,7 +572,7 @@ pub fn layout(text: &TextData, box_width: Option, box_height: Option) let baseline = y + (lh - (asc + desc)) / 2.0 + asc; // Trailing spaces don't count for width/alignment. let mut vis_end = *e; - while vis_end > *s && items.get(vis_end - 1).is_some_and(|it| it.is_space() || it.ch == '\n') { + while vis_end > *s && items.get(vis_end - 1).is_some_and(|it| it.is_space() || is_line_break(it.ch)) { vis_end -= 1; } // Letter spacing separates glyphs: none after the line's last visible one. @@ -563,7 +589,9 @@ pub fn layout(text: &TextData, box_width: Option, box_height: Option) // Justify: extra space per gap. let gaps = items.get(*s..vis_end).unwrap_or(&[]).iter().filter(|it| it.is_space()).count(); let justify_extra = match (text.align_h, wrap) { - (TextAlignH::Justified, Some(maxw)) if !para_end && gaps > 0 => ((maxw - line_indent + hang_l + hang_r - width) / gaps as f64).max(0.0), + (TextAlignH::Justified, Some(maxw)) if !para_end && !break_after(*e) && gaps > 0 => { + ((maxw - line_indent + hang_l + hang_r - width) / gaps as f64).max(0.0) + } _ => 0.0, }; // Where the visible text ends once justified. @@ -574,7 +602,7 @@ pub fn layout(text: &TextData, box_width: Option, box_height: Option) .get(vis_end..*e) .unwrap_or(&[]) .iter() - .filter(|it| it.ch != '\n') + .filter(|it| !is_line_break(it.ch)) .map(|it| it.adv + if it.is_space() { justify_extra } else { 0.0 }) .sum(); let mut pen = if rtl_line { -trailing } else { line_indent - hang_l }; @@ -649,7 +677,7 @@ pub fn layout(text: &TextData, box_width: Option, box_height: Option) (0..line_items.len()).collect() }; for it in order.iter().filter_map(|k| line_items.get(*k)) { - if it.ch == '\n' { + if is_line_break(it.ch) { continue; } let size = styles.get(it.style).map(|st| sanitize_size(st.font_size)).unwrap_or(12.0) * it.scale; @@ -687,6 +715,7 @@ pub fn layout(text: &TextData, box_width: Option, box_height: Option) width: width + line_indent - hang_l - hang_r, x: 0.0, para_end: *para_end, + break_after: break_after(*e), rtl: rtl_line, }); y += lh; @@ -1006,6 +1035,18 @@ fn is_emoji(c: char) -> 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, @@ -1036,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() { @@ -1142,16 +1185,12 @@ impl TextLayout { pub fn caret(&self, index: usize) -> (f64, usize) { for (li, line) in self.lines.iter().enumerate() { let last_line = li + 1 == self.lines.len(); + // A caret at a line's end belongs to the next line, whether the line wraps or ends with a + // line break (before the break it is still on this line: past its glyphs). let in_line = index >= line.char_start && (index < line.char_end || (last_line && index <= line.char_end)); - // A caret at the end of a soft-wrapped line belongs to the next line. - if !(in_line || index == line.char_end && line.para_end && !last_line) { + if !in_line { continue; } - if index == line.char_end && line.para_end && !last_line { - // Before the newline: end of this line. - let x = self.line_end_x(line); - return (x, li); - } let gl = self.glyphs.get(line.glyph_start..line.glyph_end).unwrap_or(&[]); for g in gl { if g.char_end == g.char_start { @@ -1179,8 +1218,8 @@ impl TextLayout { if !line.rtl && !gl.iter().any(|g| g.rtl) { return None; } - // The caret can't sit after a paragraph's newline on its own line. - let end = if line.para_end && li + 1 < self.lines.len() { line.char_end.saturating_sub(1) } else { line.char_end }; + // The caret can't sit after a line's break on its own line. + let end = if line.break_after && li + 1 < self.lines.len() { line.char_end.saturating_sub(1) } else { line.char_end }; let next = (line.char_start..=end.max(line.char_start)) .filter(|i| *i != index) .map(|i| (i, self.caret(i).0)) @@ -1247,7 +1286,7 @@ impl TextLayout { } } let last_line = li + 1 == self.lines.len(); - let end = if line.para_end && !last_line { line.char_end.saturating_sub(1).max(line.char_start) } else { line.char_end }; + let end = if line.break_after && !last_line { line.char_end.saturating_sub(1).max(line.char_start) } else { line.char_end }; // Right of a right-to-left line is its start. if line.rtl { line.char_start } else { end } } @@ -1274,12 +1313,11 @@ impl TextLayout { out.extend(Self::mixed_selection(gl, a, b, line)); continue; } - // From the line's own start: `caret(char_start)` after a hard break is the end of the - // line above. + // From the line's own start. let x0 = if a <= line.char_start { self.line_start_x(line) } else { self.caret(a).0 }; let x1 = if b >= line.char_end { // The last line has no paragraph break to show, so it ends at its glyphs. - self.line_end_x(line) + if line.para_end && !last && b > line.char_end.saturating_sub(1) { 4.0 } else { 0.0 } + self.line_end_x(line) + if line.break_after && !last && b > line.char_end.saturating_sub(1) { 4.0 } else { 0.0 } } else { self.caret(b).0 }; diff --git a/crates/text/src/lib.rs b/crates/text/src/lib.rs index 683350f6..50cd28dd 100644 --- a/crates/text/src/lib.rs +++ b/crates/text/src/lib.rs @@ -16,8 +16,9 @@ mod onpath; pub use breaks::grapheme_step; pub use color::{ColorBitmap, ColorInk, ColorLayer}; pub use fontdb::{ - Axis, CRAFT_FONTS, Coords, Face, FontDb, NamedInstance, axis_label, db, face_with_char, family_names, family_styles, find_face, has_family, - installed_fonts_known, no_coords, scan_system_fonts, scan_system_fonts_on_demand, system_fonts_scanned, + Axis, CRAFT_FONTS, Coords, Face, FontDb, NamedInstance, Rescan, axis_label, db, face_with_char, family_names, family_styles, find_face, + has_family, installed_fonts_known, load_font_bytes, no_coords, rescan_system_fonts, scan_system_fonts, scan_system_fonts_on_demand, + system_fonts_scanned, }; pub use layout::{Decoration, Glyph, Line, TextLayout, layout}; pub use onpath::OnPath; @@ -150,6 +151,8 @@ mod behaviour_tests; #[cfg(test)] mod cache_tests; #[cfg(test)] +mod line_separator_tests; +#[cfg(test)] mod list_tests; #[cfg(test)] mod metrics_tests; diff --git a/crates/text/src/line_separator_tests.rs b/crates/text/src/line_separator_tests.rs new file mode 100644 index 00000000..13f91786 --- /dev/null +++ b/crates/text/src/line_separator_tests.rs @@ -0,0 +1,97 @@ +//! A line separator (U+2028: a soft break in `.fig` files, kept on import) breaks the +//! line inside its paragraph: no paragraph spacing or new list marker after it, and no glyph (#852). + +use super::*; +use figcraft_doc::{LineHeight, ListStyle, TextAutoResize, TextData}; + +const EPS: f64 = 1e-6; + +/// A 20 px text on 30 px lines in the default face, or None (skip) on a machine without fonts. +fn text(s: &str) -> Option { + scan_system_fonts(); + let f = db().write().unwrap().default_face()?; + let mut t = TextData::new(s); + (t.style.font_family, t.style.font_style) = (f.family.clone(), f.style.clone()); + t.style.font_size = 20.0; + t.style.line_height = LineHeight::Pixels(30.0); + t.auto_resize = TextAutoResize::WidthAndHeight; + Some(t) +} + +/// The chars of each line. +fn line_texts(t: &TextData, l: &TextLayout) -> Vec { + let chars: Vec = t.characters.chars().collect(); + l.lines.iter().map(|ln| chars.get(ln.char_start..ln.char_end).unwrap_or(&[]).iter().collect::()).collect() +} + +#[test] +fn breaks_the_line_but_not_the_paragraph() { + let Some(mut t) = text("one\u{2028}two\nthree") else { return }; + t.paragraph_spacing = 10.0; + let l = layout(&t, None, None); + assert_eq!(line_texts(&t, &l), ["one\u{2028}", "two\n", "three"]); + assert!(!l.lines[0].para_end && l.lines[0].break_after); + assert!(l.lines[1].para_end && l.lines[1].break_after); + assert!(!l.lines[2].break_after); + // Paragraph spacing after the newline only: 30, then 30 + 10. + assert!((l.lines[1].top - 30.0).abs() < EPS && (l.lines[2].top - 70.0).abs() < EPS, "{} {}", l.lines[1].top, l.lines[2].top); + // The separator draws nothing. + assert!(l.glyphs.iter().all(|g| !(g.char_start..g.char_end).contains(&3))); +} + +#[test] +fn before_a_newline_or_at_the_end_it_leaves_an_empty_line() { + // "Hi,⏎¶Season" in #852: an empty line between the two. + let Some(t) = text("Hi,\u{2028}\nSeason") else { return }; + let l = layout(&t, None, None); + assert_eq!(line_texts(&t, &l), ["Hi,\u{2028}", "\n", "Season"]); + let Some(t) = text("end\u{2028}") else { return }; + let l = layout(&t, None, None); + assert_eq!(line_texts(&t, &l), ["end\u{2028}", ""]); + assert!((l.height - 60.0).abs() < EPS, "{}", l.height); +} + +#[test] +fn a_list_item_continues_after_it_without_a_new_marker() { + let Some(mut t) = text("item\u{2028}more\nnext") else { return }; + t.list = ListStyle::Bulleted; + let l = layout(&t, None, None); + assert_eq!(line_texts(&t, &l), ["item\u{2028}", "more\n", "next"]); + // Markers are the glyphs that cover no char: one per paragraph. + assert_eq!(l.glyphs.iter().filter(|g| g.char_start == g.char_end).count(), 2); + // The line after the separator lines up with the item's text, past the bullet. + let text_x = |li: usize| l.glyphs.iter().find(|g| g.char_start == l.lines[li].char_start && g.char_end > g.char_start).map(|g| g.x); + let (first, cont) = (text_x(0).unwrap(), text_x(1).unwrap()); + assert!(first > 0.0 && (first - cont).abs() < 1e-3, "{first} {cont}"); +} + +#[test] +fn carets_and_clicks_treat_it_like_a_newline() { + let (Some(sep), Some(nl)) = (text("ab\u{2028}cd"), text("ab\ncd")) else { return }; + let (ls, ln) = (layout(&sep, None, None), layout(&nl, None, None)); + for i in 0..=5 { + assert_eq!(ls.caret(i), ln.caret(i), "caret before char {i}"); + } + for line in &ln.lines { + for x in [-10.0, 5.0, line.width + 50.0] { + assert_eq!(ls.hit(x, line.top + 5.0), ln.hit(x, line.top + 5.0), "click at {x} on the line at {}", line.top); + } + } + // Before the separator: the end of the first line; after it: the start of the second. + assert_eq!(ls.caret(2).1, 0); + assert!(ls.caret(2).0 > 0.0); + assert_eq!(ls.caret(3), (0.0, 1)); + // A click right of the first line lands before the separator, not after it. + assert_eq!(ls.hit(ls.lines[0].width + 50.0, ls.lines[0].top + 5.0), 2); +} + +#[test] +fn a_selection_across_it_shows_the_break_like_a_newline() { + let (Some(sep), Some(nl)) = (text("ab\u{2028}cd"), text("ab\ncd")) else { return }; + let (ls, ln) = (layout(&sep, None, None), layout(&nl, None, None)); + let (before, across) = (ls.selection_rects(0, 2), ls.selection_rects(0, 5)); + assert_eq!((before.len(), across.len()), (1, 2)); + // Reaching past the separator adds the 4 px break mark at the first line's end. + assert!((across[0].width() - before[0].width() - 4.0).abs() < EPS, "{} {}", across[0].width(), before[0].width()); + assert_eq!(across, ln.selection_rects(0, 5)); +} diff --git a/crates/text/src/tests.rs b/crates/text/src/tests.rs index f5eaab6b..4475fbbc 100644 --- a/crates/text/src/tests.rs +++ b/crates/text/src/tests.rs @@ -481,3 +481,41 @@ fn selection_after_a_line_break_starts_at_the_line_start() { // A break followed by text still doesn't highlight the next line's start. assert!(rect_on(&l, &l.selection_rects(0, 6), 1).is_none()); } + +#[test] +fn the_caret_after_a_line_break_sits_on_the_next_line() { + if !have_fonts() { + return; + } + // Before the break: the end of its line; after it: the start of the next (after Enter, the + // caret shows on the new line, and Up/Down count from there). + let l = layout(&TextData::new("ab\ncd"), None, None); + assert_eq!(l.caret(2).1, 0); + assert!(l.caret(2).0 > 0.0); + assert_eq!(l.caret(3), (0.0, 1)); + // A trailing break: the caret sits on the empty last 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/Cargo.toml b/crates/ui-egui/Cargo.toml index 65a3592d..09cd2226 100644 --- a/crates/ui-egui/Cargo.toml +++ b/crates/ui-egui/Cargo.toml @@ -22,6 +22,7 @@ egui = { workspace = true } serde = { workspace = true } serde_json = { workspace = true } log = { workspace = true } +chrono = { workspace = true } base64 = { workspace = true } image = { workspace = true } egui_extras = { workspace = true } diff --git a/crates/ui-egui/locales/bn.toml b/crates/ui-egui/locales/bn.toml new file mode 100644 index 00000000..bc7c948c --- /dev/null +++ b/crates/ui-egui/locales/bn.toml @@ -0,0 +1,1786 @@ +# FigCraft interface strings: বাংলা, ইংরেজি থেকে অনূদিত। +# +# কীগুলো কোডে ব্যবহৃত ইংরেজি টেক্সট (`tr("…")`, `trf`, `trn`, `tr_noop`); মানগুলো অনূদিত, +# বহুবচন এন্ট্রি ({ one, other }) ছাড়া। কমান্ডের লেবেল ইঞ্জিন থেকে আসে। +# `cargo test -p figcraft-ui-egui i18n` অনুপস্থিত ও অব্যবহৃত কী তালিকাভুক্ত করে। docs/translating.md দেখুন। + +[meta] +name = "বাংলা" +status = "draft — needs native review" + +[ui] +"% of all commits" = "সমস্ত কমিটের শতাংশ" +"(no longer published)" = "(প্রকাশিত নয়)" +"({n} warnings: some entries were skipped)" = { one = "({n}টি সতর্কতা: কিছু এন্ট্রি বাদ দেওয়া হয়েছে)", other = "({n}টি সতর্কতা: কিছু এন্ট্রি বাদ দেওয়া হয়েছে)" } +"+ Create variable" = "+ ভেরিয়েবল তৈরি করুন" +".fig (experimental)" = ".fig (পরীক্ষামূলক)" +"1…9, 0" = "১…৯, ০" +"@{login}: {prs} PRs, {commits} commits, +{added} / −{deleted} lines (Δ {delta}), +{bin_added} / −{bin_deleted} binary assets, {first} – {last}" = "@{login}: {prs}টি PR, {commits}টি কমিট, +{added} / −{deleted} লাইন (Δ {delta}), +{bin_added} / −{bin_deleted} বাইনারি অ্যাসেট, {first} – {last}" +"A collection of this file with these modes and variables: override their values per mode and add variables of its own" = "এই মোড এবং ভেরিয়েবল সহ এই ফাইলের একটি সংগ্রহ: প্রতি মোডে তাদের মান ওভাররাইড করুন এবং এর নিজস্ব ভেরিয়েবল যোগ করুন" +"A .figcraft file with this name already exists. Choose its full name to confirm replacement." = "এই নামের একটি .figcraft ফাইল ইতিমধ্যে বিদ্যমান। প্রতিস্থাপন নিশ্চিত করতে এর সম্পূর্ণ নাম বেছে নিন।" +"A layer on its own aligns to its frame: select more layers, or one in a frame" = "একটি লেয়ার একা তার ফ্রেমের সাথে সারিবদ্ধ হয়: আরও লেয়ার নির্বাচন করুন, বা ফ্রেমে থাকা একটি লেয়ার নির্বাচন করুন" +"A responsive product page: hero, features, figures, a quote and a footer" = "একটি রেসপনসিভ প্রোডাক্ট পেজ: হিরো, ফিচার, ফিগার, একটি উদ্ধৃতি এবং একটি ফুটার" +"A spring plays until it settles: its mass, stiffness and damping set how long" = "একটি স্প্রিং স্থির হওয়া পর্যন্ত চলে: এর ভর, দৃঢ়তা (stiffness) এবং ড্যাম্পিং নির্ধারণ করে কতক্ষণ চলবে" +"A title, an agenda, a big figure, a chart and a closing slide" = "একটি শিরোনাম, একটি আলোচ্যসূচি, একটি বড় ফিগার, একটি চার্ট এবং একটি সমাপ্তি স্লাইড" +"A to Z" = "A থেকে Z" +"About" = "সম্পর্কিত" +"About FigCraft" = "FigCraft সম্পর্কে" +"About this server" = "এই সার্ভার সম্পর্কে" +"Action" = "অ্যাকশন" +"Actions" = "অ্যাকশনসমূহ" +"Actions for {name}" = "{name}-এর জন্য অ্যাকশন" +"Actions…" = "অ্যাকশনসমূহ..." +"Active" = "সক্রিয়" +"Actual size (100%)" = "প্রকৃত আকার (১০০%)" +"Add a category" = "একটি বিভাগ যোগ করুন" +"Add a comment" = "একটি মন্তব্য যোগ করুন" +"Add a description" = "একটি বিবরণ যোগ করুন" +"Add a frame (F) or a shape (R) to get started" = "শুরু করতে একটি ফ্রেম (F) বা শেপ (R) যোগ করুন" +"Add a layer or component to tile first" = "প্রথমে টাইল করার জন্য একটি লেয়ার বা কম্পোনেন্ট যোগ করুন" +"Add a note for developers" = "ডেভেলপারদের জন্য একটি নোট যোগ করুন" +"Add action" = "অ্যাকশন যোগ করুন" +"Add actions for when no condition holds" = "কোনো শর্ত পূর্ণ না হলে তার জন্য অ্যাকশন যোগ করুন" +"Add an annotation for developers" = "ডেভেলপারদের জন্য একটি অ্যানোটেশন যোগ করুন" +"Add another condition" = "আরেকটি শর্ত যোগ করুন" +"Add auto layout" = "অটো লেআউট যোগ করুন" +"Add background behind overlay" = "ওভারলে-এর পেছনে ব্যাকগ্রাউন্ড যোগ করুন" +"Add content to this slot" = "এই স্লটে কন্টেন্ট যোগ করুন" +"Add dev resource" = "ডেভ রিসোর্স যোগ করুন" +"Add effect" = "ইফেক্ট যোগ করুন" +"Add export setting" = "এক্সপোর্ট সেটিংস যোগ করুন" +"Add fill" = "ফিল যোগ করুন" +"Add interaction" = "ইন্টারঅ্যাকশন যোগ করুন" +"Add layout grid" = "লেআউট গ্রিড যোগ করুন" +"Add library" = "লাইব্রেরি যোগ করুন" +"Add library file…" = "লাইব্রেরি ফাইল যোগ করুন..." +"Add max height" = "সর্বোচ্চ উচ্চতা যোগ করুন" +"Add max width" = "সর্বোচ্চ প্রস্থ যোগ করুন" +"Add min height" = "সর্বনিম্ন উচ্চতা যোগ করুন" +"Add min width" = "সর্বনিম্ন প্রস্থ যোগ করুন" +"Add mode" = "মোড যোগ করুন" +"Add new page" = "নতুন পেজ যোগ করুন" +"Add or update collections from a variables JSON" = "একটি ভেরিয়েবল JSON থেকে সংগ্রহ যোগ বা আপডেট করুন" +"Add properties to make instances configurable" = "ইনস্ট্যান্সগুলোকে কনফিগারযোগ্য করতে প্রপার্টি যোগ করুন" +"Add reaction" = "রিঅ্যাকশন যোগ করুন" +"Add starting point" = "শুরু করার বিন্দু যোগ করুন" +"Add stroke" = "স্ট্রোক যোগ করুন" +"Add variant" = "ভ্যারিয়েন্ট যোগ করুন" +"Add variant with a new value" = "একটি নতুন মান সহ ভ্যারিয়েন্ট যোগ করুন" +"Added" = "যোগ করা হয়েছে" +"Added the library \"{name}\"" = "\"{name}\" লাইব্রেরি যোগ করা হয়েছে" +"Adding to {name}" = "{name}-এ যোগ করা হচ্ছে" +"Adjust" = "সামঞ্জস্য করুন" +"Adjustment presets" = "অ্যাডজাস্টমেন্ট প্রিসেট" +"Advanced" = "উন্নত" +"Advanced stroke" = "উন্নত স্ট্রোক" +"After delay" = "বিলম্বের পর" +"After delay {ms}ms" = "{ms}ms বিলম্বের পর" +"Align" = "সারিবদ্ধ করুন" +"Align bottom" = "নিচে সারিবদ্ধ করুন" +"Align center" = "কেন্দ্রে সারিবদ্ধ করুন" +"Align horizontal centers" = "অনুভূমিক কেন্দ্র বরাবর সারিবদ্ধ করুন" +"Align left" = "বামে সারিবদ্ধ করুন" +"Align middle" = "মাঝামাঝি সারিবদ্ধ করুন" +"Align right" = "ডানে সারিবদ্ধ করুন" +"Align text baselines" = "টেক্সট বেসলাইন সারিবদ্ধ করুন" +"Align top" = "উপরে সারিবদ্ধ করুন" +"Align vertical centers" = "উল্লম্ব কেন্দ্র বরাবর সারিবদ্ধ করুন" +"Alignment" = "সারিবদ্ধকরণ" +"All" = "সব" +"All categories" = "সব বিভাগ" +"All comments" = "সব মন্তব্য" +"All components" = "সব কম্পোনেন্ট" +"All fonts" = "সব ফন্ট" +"All supported properties" = "সমর্থিত সব প্রপার্টি" +"All the fonts this file uses are installed" = "এই ফাইলে ব্যবহৃত সব ফন্ট ইনস্টল করা আছে" +"All variables" = "সব ভেরিয়েবল" +"Alpha" = "আলফা" +"Alphabetical" = "বর্ণানুক্রমিক" +"Alt+drag" = "Alt+ড্র্যাগ" +"An analytics screen on a grid: figures, charts drawn from vectors and a table" = "গ্রিডের উপর একটি অ্যানালিটিক্স স্ক্রিন: ফিগার, ভেক্টর থেকে আঁকা চার্ট এবং একটি টেবিল" +"An invite link that opens FigCraft and joins" = "একটি আমন্ত্রণ লিংক যা FigCraft খুলে যুক্ত হয়" +"Anchor" = "অ্যাঙ্কর" +"Android" = "অ্যান্ড্রয়েড" +"Angular" = "অ্যাঙ্গুলার" +"Animation" = "অ্যানিমেশন" +"Annotations" = "অ্যানোটেশন" +"Annotations shown on the canvas" = "ক্যানভাসে অ্যানোটেশনগুলো দেখানো হচ্ছে" +"Another server, or one that needs its key. The usual server is set in Preferences." = "অন্য একটি সার্ভার, অথবা যার কী প্রয়োজন। সাধারণ সার্ভারটি প্রেফারেন্সেসে সেট করা আছে।" +"Any status" = "যেকোনো স্ট্যাটাস" +"Anyone on your network with this code can edit the file, while this app stays open." = "এই অ্যাপ খোলা থাকা অবস্থায় আপনার নেটওয়ার্কে এই কোড জানা যেকোনো ব্যক্তি ফাইলটি এডিট করতে পারবেন।" +"Anyone with the edit link can change the file; the view link only lets people look. The links carry their secrets: share them like passwords. `figcraft-server links` makes more and revokes them." = "এডিট লিংক থাকা যেকোনো ব্যক্তি ফাইলটি পরিবর্তন করতে পারবেন; ভিউ লিংক শুধু দেখতে দেয়। লিংকগুলো গোপন তথ্য বহন করে: পাসওয়ার্ডের মতো এগুলো শেয়ার করুন। `figcraft-server links` কমান্ড দিয়ে আরও তৈরি ও বাতিল করা যায়।" +"Anyone with this code can edit the file." = "এই কোড জানা যেকোনো ব্যক্তি ফাইলটি এডিট করতে পারবেন।" +"Anyone with this code can look at the file, not change it." = "এই কোড জানা যেকোনো ব্যক্তি ফাইলটি দেখতে পারবেন, পরিবর্তন করতে পারবেন না।" +"Appearance" = "চেহারা" +"Apply a component property" = "একটি কম্পোনেন্ট প্রপার্টি প্রয়োগ করুন" +"Apply variable" = "ভেরিয়েবল প্রয়োগ করুন" +"Apply variable to text" = "টেক্সটে ভেরিয়েবল প্রয়োগ করুন" +"Apply variable to visibility" = "দৃশ্যমানতায় ভেরিয়েবল প্রয়োগ করুন" +"Apps" = "অ্যাপস" +"Apr" = "এপ্রিল" +"Arc" = "আর্ক" +"Arc ratio" = "আর্ক অনুপাত" +"Arc start" = "আর্ক শুরু" +"Arc sweep" = "আর্ক সুইপ" +"Archive" = "আর্কাইভ" +"Archived" = "আর্কাইভ করা হয়েছে" +"Arrange" = "সাজান" +"Arrow" = "তীর" +"Arrow at the end" = "শেষে তীর" +"As typed" = "যেমনটি টাইপ করা হয়েছে" +"Asking the server…" = "সার্ভারকে জিজ্ঞাসা করা হচ্ছে..." +"Assets" = "অ্যাসেট" +"Aug" = "আগস্ট" +"Auto" = "অটো" +"Auto (space around)" = "অটো (চারপাশে স্পেস)" +"Auto (space between)" = "অটো (মাঝখানে স্পেস)" +"Auto (space evenly)" = "অটো (সমানভাবে স্পেস)" +"Auto ({mode})" = "অটো ({mode})" +"Auto columns" = "অটো কলাম" +"Auto gap" = "অটো গ্যাপ" +"Auto height" = "অটো উচ্চতা" +"Auto layout" = "অটো লেআউট" +"Auto rows" = "অটো সারি" +"Auto width" = "অটো প্রস্থ" +"Back to the FigCraft server" = "FigCraft সার্ভারে ফিরে যান" +"Back to the font's value" = "ফন্টের মানে ফিরে যান" +"Back" = "পেছনে" +"Back to Ready for dev" = "ডেভের জন্য প্রস্তুত অবস্থায় ফিরে যান" +"Back to Ready for dev: the next change compares with how it is now" = "ডেভের জন্য প্রস্তুত অবস্থায় ফিরে যান: পরবর্তী পরিবর্তনটি বর্তমান অবস্থার সাথে তুলনা করবে" +"Back to assets" = "অ্যাসেটে ফিরে যান" +"Back to current" = "বর্তমানে ফিরে যান" +"Back to styles" = "স্টাইলে ফিরে যান" +"Back to the canvas from a focused region" = "ফোকাসড অঞ্চল থেকে ক্যানভাসে ফিরে যান" +"Back to the current version" = "বর্তমান সংস্করণে ফিরে যান" +"Back to the values the font style implies" = "ফন্ট স্টাইল নির্দেশিত মানে ফিরে যান" +"Background" = "ব্যাকগ্রাউন্ড" +"Background blur" = "ব্যাকগ্রাউন্ড ব্লার" +"Basics" = "মৌলিক বিষয়" +"Before" = "আগে" +"Bend" = "বাঁক" +"Bevel" = "বেভেল" +"Binary assets added" = "বাইনারি অ্যাসেট যোগ করা হয়েছে" +"Binary assets removed" = "বাইনারি অ্যাসেট সরানো হয়েছে" +"Black & white" = "কালো ও সাদা" +"Bleed" = "ব্লিড" +"Bleed marks" = "ব্লিড মার্ক" +"Bleed marks need a bleed" = "ব্লিড মার্কের জন্য ব্লিড প্রয়োজন" +"Blend" = "ব্লেন্ড" +"Blend mode" = "ব্লেন্ড মোড" +"Blue" = "নীল" +"Blur" = "ব্লার" +"Boolean" = "বুলিয়ান" +"Boolean groups" = "বুলিয়ান গ্রুপ" +"Both directions" = "উভয় দিক" +"Bottom" = "নিচে" +"Bottom left radius" = "নিচের বাম ব্যাসার্ধ" +"Bottom right radius" = "নিচের ডান ব্যাসার্ধ" +"Branch" = "ব্রাঞ্চ" +"Branch archived" = "ব্রাঞ্চ আর্কাইভ করা হয়েছে" +"Branch merged" = "ব্রাঞ্চ মার্জ করা হয়েছে" +"Branch name" = "ব্রাঞ্চের নাম" +"Branch of {file}" = "{file}-এর ব্রাঞ্চ" +"Branch of {file} · merged or archived" = "{file}-এর ব্রাঞ্চ · মার্জ বা আর্কাইভ করা হয়েছে" +"Branch updated from main" = "প্রধান (main) থেকে ব্রাঞ্চ আপডেট করা হয়েছে" +"Branch {name}" = "ব্রাঞ্চ {name}" +"Branches" = "ব্রাঞ্চসমূহ" +"Bound to {name}. Click to detach the variable." = "{name}-এর সাথে আবদ্ধ। ভেরিয়েবলটি বিচ্ছিন্ন করতে ক্লিক করুন।" +"Bring forward" = "সামনে আনুন" +"Bring the main file's changes into the branch" = "প্রধান ফাইলের পরিবর্তনগুলো ব্রাঞ্চে নিয়ে আসুন" +"Bring to front" = "সবার সামনে আনুন" +"Bristle" = "ব্রিসল" +"Browse" = "ব্রাউজ করুন" +"Brush" = "ব্রাশ" +"Brush rotation jitter" = "ব্রাশ ঘূর্ণন জিটার" +"Brush scatter" = "ব্রাশ স্ক্যাটার" +"Brush size jitter" = "ব্রাশ সাইজ জিটার" +"Brush spacing" = "ব্রাশ স্পেসিং" +"Bulleted list" = "বুলেটেড তালিকা" +"C or Shift+C" = "C বা Shift+C" +"C or ⇧C" = "C বা ⇧C" +"CMYK colour" = "CMYK রঙ" +"Can view" = "দেখতে পারেন" +"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 কাস্টম প্রপার্টি..." +"Can edit" = "এডিট করতে পারেন" +"Can't read this library: {e}" = "এই লাইব্রেরি পড়া যাচ্ছে না: {e}" +"Cancel" = "বাতিল" +"Canvas" = "ক্যানভাস" +"Canvas stacking" = "ক্যানভাস স্ট্যাকিং" +"Cap height to baseline" = "বেসলাইনে ক্যাপ হাইট" +"Case" = "কেস" +"Case-sensitive forms" = "কেস-সেন্সিটিভ ফর্ম" +"Categories" = "বিভাগসমূহ" +"Center" = "কেন্দ্র" +"Change alias" = "অ্যালিয়াস পরিবর্তন করুন" +"Change resolved" = "পরিবর্তন সমাধান করা হয়েছে" +"Change style" = "স্টাইল পরিবর্তন করুন" +"Change to" = "এতে পরিবর্তন করুন" +"Change to needs a layer inside a variant" = "পরিবর্তন করার জন্য ভ্যারিয়েন্টের ভেতরে একটি লেয়ার প্রয়োজন" +"Changed" = "পরিবর্তিত" +"Changed on both sides: keep one" = "উভয় দিকেই পরিবর্তিত হয়েছে: একটি রাখুন" +"Changes on main" = "প্রধান (main)-এ পরিবর্তন" +"Changes on the branch" = "ব্রাঞ্চে পরিবর্তন" +"Changes unknown" = "পরিবর্তন অজ্ঞাত" +"Choose React, Leptos, SwiftUI or Compose above to connect this component to its code." = "এই কম্পোনেন্টটিকে কোডের সাথে যুক্ত করতে উপরে React, Leptos, SwiftUI বা Compose বেছে নিন।" +"Choose another profile to change it." = "পরিবর্তন করতে অন্য একটি প্রোফাইল বেছে নিন।" +"Choose collection" = "সংগ্রহ (collection) বেছে নিন" +"Choose image" = "ছবি বেছে নিন" +"Choose mode" = "মোড বেছে নিন" +"Choose variable" = "ভেরিয়েবল বেছে নিন" +"Circle" = "বৃত্ত" +"Clear recent" = "সাম্প্রতিকগুলো মুছে ফেলুন" +"Click + to replace mixed content" = "মিশ্র কন্টেন্ট প্রতিস্থাপন করতে + এ ক্লিক করুন" +"Click anywhere on the canvas to leave one." = "একটি রাখতে ক্যানভাসের যেকোনো জায়গায় ক্লিক করুন।" +"Click or drag to merge · {alt} removes" = "মার্জ করতে ক্লিক বা ড্র্যাগ করুন · {alt} সরায়" +"Click or drag to remove · {alt} merges" = "সরাতে ক্লিক বা ড্র্যাগ করুন · {alt} মার্জ করে" +"Click to record a key" = "একটি কি রেকর্ড করতে ক্লিক করুন" +"Clip content" = "ক্লিপ কন্টেন্ট" +"Clip to shape" = "শেপে ক্লিপ করুন" +"Close" = "বন্ধ করুন" +"Close overlay" = "ওভারলে বন্ধ করুন" +"Close preview" = "প্রিভিউ বন্ধ করুন" +"Close tab" = "ট্যাব বন্ধ করুন" +"Close version history" = "সংস্করণ ইতিহাস বন্ধ করুন" +"Close when clicking outside" = "বাইরে ক্লিক করলে বন্ধ করুন" +"Code" = "কোড" +"Code syntax" = "কোড সিনট্যাক্স" +"Collapse" = "সংকুচিত করুন" +"Collapse panel" = "প্যানেল সংকুচিত করুন" +"Collection" = "সংগ্রহ" +"Collections" = "সংগ্রহসমূহ" +"Color" = "রঙ" +"Color burn" = "কালার বার্ন" +"Color dodge" = "কালার ডজ" +"Color format" = "কালার ফরম্যাট" +"Color profile" = "কালার প্রোফাইল" +"Color styles" = "কালার স্টাইল" +"Color values in this file are in this color space." = "এই ফাইলের রঙের মানগুলো এই কালার স্পেসে রয়েছে।" +"Color variables" = "কালার ভেরিয়েবল" +"Colour" = "রঙ" +"Column" = "কলাম" +"Column gap" = "কলাম গ্যাপ" +"Column sizes" = "কলামের আকার" +"Column span" = "কলাম স্প্যান" +"Columns" = "কলামসমূহ" +"Columns {count}" = "কলাম {count}" +"Comment" = "মন্তব্য" +"Comments" = "মন্তব্যসমূহ" +"Commits" = "কমিটসমূহ" +"Company" = "কোম্পানি" +"Compare changes" = "পরিবর্তন তুলনা করুন" +"Compatibility notes" = "সঙ্গতি নোট (Compatibility notes)" +"Completed" = "সম্পন্ন" +"Component" = "কম্পোনেন্ট" +"Component name" = "কম্পোনেন্টের নাম" +"Component property" = "কম্পোনেন্ট প্রপার্টি" +"Component set" = "কম্পোনেন্ট সেট" +"Component sets" = "কম্পোনেন্ট সেটসমূহ" +"Components" = "কম্পোনেন্টস" +"Conditional" = "শর্তসাপেক্ষ" +"Confetti" = "কনফেটি" +"Conflicts" = "দ্বন্দ্ব (Conflicts)" +"Connect to code" = "কোডের সাথে সংযুক্ত করুন" +"Connected" = "সংযুক্ত" +"Connected to code: {from}" = "কোডের সাথে সংযুক্ত: {from}" +"Connection lost, reconnecting (attempt {attempt})…" = "সংযোগ বিচ্ছিন্ন হয়েছে, পুনরায় সংযোগ স্থাপন করা হচ্ছে (চেষ্টা {attempt})..." +"Constrain proportions" = "অনুপাত ঠিক রাখুন" +"Constraints" = "সীমাবদ্ধতা (Constraints)" +"Content" = "কন্টেন্ট" +"Contents hidden" = "কন্টেন্ট লুকানো আছে" +"Contents only" = "শুধু কন্টেন্ট" +"Contextual alternates" = "প্রসঙ্গভিত্তিক বিকল্প" +"Contrast" = "কনট্রাস্ট (Contrast)" +"Contributors" = "অবদানকারীগণ" +"Contributors: {people} · Commits: {commits}" = "অবদানকারী: {people} · কমিট: {commits}" +"Controller" = "কন্ট্রোলার" +"Cool" = "কুল" +"Copied as CSS" = "CSS হিসেবে কপি করা হয়েছে" +"Copied as PNG" = "PNG হিসেবে কপি করা হয়েছে" +"Copied as SVG" = "SVG হিসেবে কপি করা হয়েছে" +"Copied as text" = "টেক্সট হিসেবে কপি করা হয়েছে" +"Copied {value}" = "{value} কপি করা হয়েছে" +"Copy" = "কপি করুন" +"Copy as" = "যেভাবে কপি করবেন" +"Copy as CSS" = "CSS হিসেবে কপি করুন" +"Copy as PNG" = "PNG হিসেবে কপি করুন" +"Copy as SVG" = "SVG হিসেবে কপি করুন" +"Copy code" = "কোড কপি করুন" +"Copy link" = "লিংক কপি করুন" +"Copy the {label}" = "{label} কপি করুন" +"Copy {value}" = "{value} কপি করুন" +"Corner radius" = "কোণার ব্যাসার্ধ (Corner radius)" +"Corner smoothing" = "কোণা মসৃণকরণ" +"Couldn't download the variables" = "ভেরিয়েবলগুলো ডাউনলোড করা যায়নি" +"Couldn't reach {server}: showing what this computer remembers." = "{server}-এ পৌঁছানো যায়নি: এই কম্পিউটার যা মনে রেখেছে তা দেখানো হচ্ছে।" +"Couldn't save automatically: {error}" = "স্বয়ংক্রিয়ভাবে সেভ করা যায়নি: {error}" +"Count" = "গণনা" +"Create" = "তৈরি করুন" +"Create a variable collection first" = "প্রথমে একটি ভেরিয়েবল সংগ্রহ তৈরি করুন" +"Create a variable first" = "প্রথমে একটি ভেরিয়েবল তৈরি করুন" +"Create alias" = "অ্যালিয়াস তৈরি করুন" +"Create branch" = "ব্রাঞ্চ তৈরি করুন" +"Create collection" = "সংগ্রহ তৈরি করুন" +"Create component" = "কম্পোনেন্ট তৈরি করুন" +"Create component property" = "কম্পোনেন্ট প্রপার্টি তৈরি করুন" +"Create components, styles or variables to publish them as a library." = "লাইব্রেরি হিসেবে প্রকাশ করতে কম্পোনেন্ট, স্টাইল বা ভেরিয়েবল তৈরি করুন।" +"Create property…" = "প্রপার্টি তৈরি করুন..." +"Create style" = "স্টাইল তৈরি করুন" +"Create style from the selected text" = "নির্বাচিত টেক্সট থেকে স্টাইল তৈরি করুন" +"Created" = "তৈরি করা হয়েছে" +"Crop" = "ক্রপ" +"Crop marks" = "ক্রপ মার্ক" +"Ctrl+arrows" = "Ctrl+অ্যারো" +"Ctrl+click" = "Ctrl+ক্লিক" +"Ctrl+scroll" = "Ctrl+স্ক্রল" +"Current name" = "বর্তমান নাম" +"Current version" = "বর্তমান সংস্করণ" +"Cursor chat" = "কার্সার চ্যাট" +"Custom" = "কাস্টম" +"Custom size" = "কাস্টম আকার" +"Cut" = "কাট করুন" +"Dark" = "ডার্ক" +"Darken" = "ডার্কেন" +"Dash" = "ড্যাশ" +"Dashboard" = "ড্যাশবোর্ড" +"Date created" = "তৈরির তারিখ" +"Dec" = "ডিসেম্বর" +"Decoration style" = "সজ্জা স্টাইল (Decoration style)" +"Decoration" = "সজ্জা" +"Deep select" = "ডিপ সিলেক্ট" +"Default" = "ডিফল্ট" +"Default content" = "ডিফল্ট কন্টেন্ট" +"Default value (the top-left variant)" = "ডিফল্ট মান (উপরের-বাম ভ্যারিয়েন্ট)" +"Delay" = "বিলম্ব" +"Delete" = "ডিলিট করুন" +"Delete category (its annotations stay)" = "বিভাগ ডিলিট করুন (এর অ্যানোটেশনগুলো থেকে যাবে)" +"Delete collection" = "সংগ্রহ ডিলিট করুন" +"Delete group" = "গ্রুপ ডিলিট করুন" +"Delete mode" = "মোড ডিলিট করুন" +"Delete page" = "পেজ ডিলিট করুন" +"Delete preset" = "প্রিসেট ডিলিট করুন" +"Delete property" = "প্রপার্টি ডিলিট করুন" +"Delete the style (layers keep their values)" = "স্টাইল ডিলিট করুন (লেয়ারগুলো তাদের মান ধরে রাখবে)" +"Delete thread" = "থ্রেড ডিলিট করুন" +"Delete variable" = "ভেরিয়েবল ডিলিট করুন" +"Delete “{name}”? This can't be undone." = "“{name}” ডিলিট করবেন? এটি আর ফেরানো যাবে না।" +"Density" = "ঘনত্ব" +"Depth" = "গভীরতা" +"Describe how and when to use this component" = "এই কম্পোনেন্টটি কীভাবে এবং কখন ব্যবহার করতে হবে তা বর্ণনা করুন" +"Describe what changed" = "কী পরিবর্তিত হয়েছে তা বর্ণনা করুন" +"Description" = "বিবরণ" +"Description (optional)" = "বিবরণ (ঐচ্ছিক)" +"Design" = "ডিজাইন" +"Design panel" = "ডিজাইন প্যানেল" +"Design system starter" = "ডিজাইন সিস্টেম স্টার্টার" +"Desktop" = "ডেস্কটপ" +"Destination" = "গন্তব্য" +"Detach alias" = "অ্যালিয়াস বিচ্ছিন্ন করুন" +"Detach instance" = "ইনস্ট্যান্স বিচ্ছিন্ন করুন" +"Detach property" = "প্রপার্টি বিচ্ছিন্ন করুন" +"Detach style" = "স্টাইল বিচ্ছিন্ন করুন" +"Detach styles" = "স্টাইলগুলো বিচ্ছিন্ন করুন" +"Detach variable" = "ভেরিয়েবল বিচ্ছিন্ন করুন" +"Details" = "বিস্তারিত" +"Dev Mode" = "ডেভ মোড" +"Dev resources" = "ডেভ রিসোর্স" +"Development" = "ডেভেলপমেন্ট" +"Device" = "ডিভাইস" +"Device at 100%" = "১০০%-এ ডিভাইস" +"Diamond" = "ডায়মন্ড" +"Difference" = "পার্থক্য" +"Dimensions" = "মাত্রা (Dimensions)" +"Direction" = "দিক" +"Disconnect from code" = "কোড থেকে সংযোগ বিচ্ছিন্ন করুন" +"Dispersion" = "বিচ্ছুরণ" +"Display name" = "প্রদর্শিত নাম" +"Dissolve" = "ডিসলভ" +"Distribute and tidy" = "বণ্টন ও পরিপাটি করুন" +"Distribute horizontal spacing" = "অনুভূমিক ব্যবধান বিতরণ করুন" +"Distribute vertical spacing" = "উল্লম্ব ব্যবধান বিতরণ করুন" +"Divider" = "ডিভাইডার" +"Don't Save" = "সংরক্ষণ করবেন না" +"Don't truncate" = "ট্রাঙ্কেট করবেন না" +"Done" = "সম্পন্ন" +"Done editing" = "সম্পাদনা শেষ" +"Dots" = "বিন্দুসমূহ" +"Dotted" = "ডটেড" +"Down" = "নিচে" +"Drafts" = "ড্রাফট" +"Drag the stroke to add a width point" = "প্রস্থ বিন্দু যোগ করতে স্ট্রোকে ড্র্যাগ করুন" +"Draw" = "আঁকুন" +"Draw a frame" = "একটি ফ্রেম আঁকুন" +"Drawing tools" = "অঙ্কন টুলস" +"Drop a .figcraft, .fig, .json, .svg or image file." = "একটি .figcraft, .fig, .json, .svg বা ছবির ফাইল ড্রপ করুন।" +"Drop to import" = "ইম্পোর্ট করতে ড্রপ করুন" +"Drop to open" = "খুলতে ড্রপ করুন" +"Drop shadow" = "ড্রপ শ্যাডো" +"Dual-licensed MIT OR Apache-2.0. Made by the ArtCraft team and community." = "ডুয়াল-লাইসেন্সকৃত MIT বা Apache-2.0। ArtCraft টিম এবং কমিউনিটি দ্বারা তৈরি।" +"Duo" = "ডুও" +"Duplicate" = "প্রতিলিপি তৈরি করুন (Duplicate)" +"Duplicate by dragging" = "ড্র্যাগ করে ডুপ্লিকেট করুন" +"Duplicate mode" = "মোড ডুপ্লিকেট করুন" +"Duplicate page" = "পেজ ডুপ্লিকেট করুন" +"Edit" = "এডিট" +"Edit as an expression" = "এক্সপ্রেশন হিসেবে এডিট করুন" +"Edit colour" = "রঙ এডিট করুন" +"Edit comment" = "মন্তব্য এডিট করুন" +"Edit dev resource" = "ডেভ রিসোর্স এডিট করুন" +"Edit link" = "লিংক এডিট করুন" +"Edit link copied" = "এডিট লিংক কপি করা হয়েছে" +"Edit style" = "স্টাইল এডিট করুন" +"Edit variable" = "ভেরিয়েবল এডিট করুন" +"Edit with the builder" = "বিল্ডার দিয়ে এডিট করুন" +"Edited {when}" = "{when} এডিট করা হয়েছে" +"Effect settings" = "ইফেক্ট সেটিংস" +"Effect styles" = "ইফেক্ট স্টাইল" +"Effects" = "ইফেক্টসমূহ" +"Elbow" = "কোণযুক্ত" +"Ellipse" = "উপবৃত্ত" +"Ellipses" = "উপবৃত্তসমূহ" +"Else" = "অন্যথায় (Else)" +"Else if" = "অন্যথায় যদি (Else if)" +"Embedded profile" = "এমবেডেড প্রোফাইল" +"Empty" = "খালি" +"End" = "শেষ" +"End point" = "শেষ বিন্দু" +"Ends the session; everyone keeps their copy" = "সেশন শেষ করে; সবাই তাদের কপি নিজের কাছে রাখে" +"Enter" = "এন্টার" +"Enter the address of a figcraft-server and press Browse to see the plugins it offers." = "একটি figcraft-সার্ভারের ঠিকানা দিন এবং এটি যে প্লাগইনগুলো অফার করে তা দেখতে ব্রাউজ টিপুন।" +"Enter to save · Esc to cancel" = "সেভ করতে Enter · বাতিল করতে Esc" +"Eraser" = "ইরেজার" +"Esc" = "Esc" +"Essential" = "অত্যাবশ্যকীয়" +"Every conflict has a choice." = "প্রতিটি দ্বন্দ্বেরই একটি সমাধান বা পছন্দ থাকে।" +"Every top-level frame already starts a flow" = "প্রতিটি শীর্ষ-স্তরের ফ্রেম ইতিমধ্যে একটি ফ্লো শুরু করে" +"Exact opacity (4 then 5: 45%)" = "সঠিক অস্বচ্ছতা (4 তারপর 5: 45%)" +"Exclude" = "বাদ দিন" +"Excluded" = "বাদ দেওয়া হয়েছে" +"Excluded from layout" = "লেআউট থেকে বাদ দেওয়া হয়েছে" +"Excludes" = "বাদ দেয়" +"Exclusion" = "এক্সক্লুশন" +"Exit focus view (Esc)" = "ফোকাস ভিউ থেকে বের হন (Esc)" +"Expand" = "প্রসারিত করুন" +"Export" = "এক্সপোর্ট" +"Export all" = "সব এক্সপোর্ট করুন" +"Export frames and layers as PNG, JPG, SVG or PDF" = "ফ্রেম এবং লেয়ারগুলোকে PNG, JPG, SVG বা PDF হিসেবে এক্সপোর্ট করুন" +"Export page as PNG" = "পেজ PNG হিসেবে এক্সপোর্ট করুন" +"Export settings" = "এক্সপোর্ট সেটিংস" +"Export variables" = "ভেরিয়েবল এক্সপোর্ট করুন" +"Export {name}" = "{name} এক্সপোর্ট করুন" +"Export {n} layers" = { one = "{n}টি লেয়ার এক্সপোর্ট করুন", other = "{n}টি লেয়ার এক্সপোর্ট করুন" } +"Exported {file}" = "{file} এক্সপোর্ট করা হয়েছে" +"Exported {name}" = "{name} এক্সপোর্ট করা হয়েছে" +"Exported {name}; {n} kinds of content were left out" = { one = "{name} এক্সপোর্ট করা হয়েছে; {n} ধরনের কন্টেন্ট বাদ দেওয়া হয়েছে", other = "{name} এক্সপোর্ট করা হয়েছে; {n} ধরনের কন্টেন্ট বাদ দেওয়া হয়েছে" } +"Exported {n} files" = { one = "{n}টি ফাইল এক্সপোর্ট করা হয়েছে", other = "{n}টি ফাইল এক্সপোর্ট করা হয়েছে" } +"Export…" = "এক্সপোর্ট..." +"Expose properties to instances" = "ইনস্ট্যান্সে প্রপার্টি উন্মুক্ত করুন" +"Exposure" = "এক্সপোজার" +"Expression" = "এক্সপ্রেশন" +"Extend collection" = "সংগ্রহ প্রসারিত করুন" +"Extends \"{name}\": overridden values are marked" = "\"{name}\"-কে প্রসারিত করে: ওভাররাইড করা মানগুলো চিহ্নিত করা আছে" +"Eye" = "চোখ" +"F" = "F" +"Fade" = "ফেড" +"False" = "মিথ্যা (False)" +"Feb" = "ফেব্রুয়ারি" +"Fetching from the server…" = "সার্ভার থেকে ডেটা আনা হচ্ছে..." +"FigCraft couldn't open its window: {error}" = "FigCraft এর উইন্ডো খুলতে পারেনি: {error}" +"FigCraft doesn't support every feature of these files yet. The notes list what was left out or changed in the imported copy; the original file is unchanged." = "FigCraft এখনো এই ফাইলগুলোর সব ফিচার সমর্থন করে না। নোটগুলোতে ইম্পোর্ট করা কপিতে যা বাদ দেওয়া হয়েছে বা পরিবর্তন করা হয়েছে তার তালিকা রয়েছে; মূল ফাইলটি অপরিবর্তিত রয়েছে।" +"File" = "ফাইল" +"File name" = "ফাইলের নাম" +"File not found" = "ফাইল পাওয়া যায়নি" +"File published" = "ফাইল প্রকাশিত হয়েছে" +"Files aren't end-to-end encrypted: the server's operator can read them." = "ফাইলগুলো এন্ড-টু-এন্ড এনক্রিপ্টেড নয়: সার্ভারের অপারেটর এগুলো পড়তে পারেন।" +"Files nobody opens for {n} days are deleted." = { one = "যে ফাইল {n} দিন কেউ খোলে না তা ডিলিট করা হয়।", other = "যে ফাইলগুলো {n} দিন কেউ খোলে না সেগুলো ডিলিট করা হয়।" } +"Fill" = "ফিল" +"Fill (fr)" = "ফিল (fr)" +"Fill container" = "কন্টেইনার পূর্ণ করুন" +"Fill window" = "উইন্ডো পূর্ণ করুন" +"Fills" = "ফিলসমূহ" +"Fill|sizing" = "ফিল" +"Filter and sort" = "ফিল্টার ও সাজান" +"Find" = "খুঁজুন" +"Find and replace" = "খুঁজুন ও প্রতিস্থাপন করুন" +"Find layers" = "লেয়ার খুঁজুন" +"First" = "প্রথম" +"First commit" = "প্রথম কমিট" +"First on top" = "প্রথমে উপরে" +"Fit" = "ফিট" +"Fit device to window" = "উইন্ডোতে ডিভাইস ফিট করুন" +"Fit to contents" = "কন্টেন্ট অনুযায়ী ফিট করুন" +"Fit to window" = "উইন্ডো অনুযায়ী ফিট করুন" +"Fit width" = "প্রস্থ ফিট করুন" +"Fixed" = "নির্দিষ্ট (Fixed)" +"Fixed gap" = "নির্দিষ্ট গ্যাপ" +"Fixed size" = "নির্দিষ্ট আকার" +"Flatten" = "ফ্ল্যাট করুন" +"Flip" = "উল্টান (Flip)" +"Flip horizontal" = "অনুভূমিকভাবে উল্টান" +"Flip text to the other side" = "টেক্সট অন্য পাশে উল্টান" +"Flip vertical" = "উল্লম্বভাবে উল্টান" +"Flip width profile" = "প্রস্থ প্রোফাইল উল্টান" +"Flow starting point" = "ফ্লো শুরুর বিন্দু" +"Flow starting points" = "ফ্লো শুরুর বিন্দুসমূহ" +"Flows" = "ফ্লোসমূহ" +"Focus view" = "ফোকাস ভিউ" +"Fold autosaves" = "স্বয়ংক্রিয় সেভ ভাঁজ করুন" +"Folder: click to collapse" = "ফোল্ডার: সংকুচিত করতে ক্লিক করুন" +"Folder: click to expand" = "ফোল্ডার: প্রসারিত করতে ক্লিক করুন" +"Follow" = "অনুসরণ করুন" +"Follow {name}" = "{name}-কে অনুসরণ করুন" +"Following" = "অনুসরণ করা হচ্ছে" +"Following {name} · {key} to stop" = "{name}-কে অনুসরণ করা হচ্ছে · থামতে {key} টিপুন" +"Font" = "ফন্ট" +"Font not installed" = "ফন্ট ইনস্টল করা নেই" +"Font size" = "ফন্টের আকার" +"Font style" = "ফন্ট স্টাইল" +"Forget on this computer" = "এই কম্পিউটার থেকে মুছে ফেলুন" +"Four screens of a trail app with components and a prototype flow" = "কম্পোনেন্ট এবং প্রোটোটাইপ ফ্লো সহ ট্রেইল অ্যাপের চারটি স্ক্রিন" +"Fractions" = "ভগ্নাংশ" +"Frame" = "ফ্রেম" +"Frame selection" = "ফ্রেম নির্বাচন" +"Frame tools" = "ফ্রেম টুলস" +"Frames" = "ফ্রেমসমূহ" +"From the library \"{name}\": read-only" = "\"{name}\" লাইব্রেরি থেকে: শুধুমাত্র পড়ার উপযোগী (read-only)" +"From {name}" = "{name} থেকে" +"From {server}" = "{server} থেকে" +"Frost" = "ফ্রস্ট" +"Gap" = "গ্যাপ" +"Gap between columns" = "কলামের মধ্যবর্তী গ্যাপ" +"Gap between items" = "আইটেমের মধ্যবর্তী গ্যাপ" +"Gap between rows" = "সারির মধ্যবর্তী গ্যাপ" +"Getting started" = "শুরু করা" +"Give the address of a server open to the public, or a server's address with its key (`figcraft-server key` prints it)." = "পাবলিকের জন্য উন্মুক্ত কোনো সার্ভারের ঠিকানা দিন, অথবা কী সহ সার্ভারের ঠিকানা দিন (`figcraft-server key` এটি প্রিন্ট করে)।" +"Glass" = "গ্লাস" +"Go to main component" = "প্রধান কম্পোনেন্টে যান" +"Go to {name}" = "{name}-এ যান" +"Grab bag" = "গ্র্যাব ব্যাগ" +"Gradient" = "গ্রেডিয়েন্ট" +"Gray" = "ধূসর" +"Green" = "সবুজ" +"Greyscale components and two example screens to sketch a flow with" = "একটি ফ্লো স্কেচ করার জন্য গ্রেস্কেল কম্পোনেন্ট এবং দুটি উদাহরণ স্ক্রিন" +"Grid" = "গ্রিড" +"Grid ({size})" = "গ্রিড ({size})" +"Grid styles" = "গ্রিড স্টাইল" +"Grid {size}" = "গ্রিড {size}" +"Group" = "গ্রুপ" +"Group selection" = "গ্রুপ নির্বাচন" +"Groups" = "গ্রুপসমূহ" +"Gutter" = "গাটার" +"Hand tool" = "হ্যান্ড টুল" +"Hanging list" = "হ্যাংগিং তালিকা" +"Hanging punctuation" = "হ্যাংগিং বিরামচিহ্ন" +"Hard light" = "হার্ড লাইট" +"Heart" = "হৃদয়" +"Height" = "উচ্চতা" +"Help" = "সহায়তা" +"Help and resources" = "সহায়তা ও রিসোর্স" +"Help page" = "সহায়তা পেজ" +"Here: {names}" = "এখানে: {names}" +"Hidden" = "লুকানো" +"Hide contents" = "কন্টেন্ট লুকান" +"Hide from publishing" = "প্রকাশ করা থেকে লুকান" +"Hide section contents" = "সেকশনের কন্টেন্ট লুকান" +"Hide these tips (Getting started in the Help menu shows them again)" = "এই টিপসগুলো লুকান (সহায়তা মেনুর 'শুরু করা' অপশনটি এগুলো আবার দেখাবে)" +"Hide when publishing" = "প্রকাশ করার সময় লুকান" +"Highlight layers on hover" = "হোভারে লেয়ার হাইলাইট করুন" +"Highlighter" = "হাইলাইটার" +"Highlights" = "হাইলাইটস" +"Hold Alt" = "Alt চেপে ধরে রাখুন" +"Hold Alt+Z" = "Alt+Z চেপে ধরে রাখুন" +"Hold Ctrl" = "Ctrl চেপে ধরে রাখুন" +"Hold Z" = "Z চেপে ধরে রাখুন" +"Hold ⌃ or ⌘" = "⌃ বা ⌘ চেপে ধরে রাখুন" +"Hold ⌥" = "⌥ চেপে ধরে রাখুন" +"Hold ⌥Z" = "⌥Z চেপে ধরে রাখুন" +"Home" = "হোম" +"Horizontal" = "অনুভূমিক" +"Horizontal align" = "অনুভূমিক সারিবদ্ধকরণ" +"Horizontal hex" = "অনুভূমিক হেক্স" +"Horizontal padding" = "অনুভূমিক প্যাডিং" +"Horizontal spacing" = "অনুভূমিক ব্যবধান" +"Hosted by {operator}" = "হোস্ট করেছেন: {operator}" +"Hover a layer to see its properties" = "প্রপার্টি দেখতে কোনো লেয়ারের উপর হোভার করুন" +"Hover other layers to measure the distance" = "দূরত্ব মাপতে অন্য লেয়ারগুলোর উপর হোভার করুন" +"Hue" = "হিউ (Hue)" +"Hug" = "হাগ" +"Hug contents" = "কন্টেন্ট অনুযায়ী হাগ করুন" +"Icon components on a 24 px grid with keylines and a size preview" = "কিলিন এবং সাইজ প্রিভিউ সহ ২৪ পিক্সেল গ্রিডে আইকন কম্পোনেন্ট" +"Icon set starter" = "আইকন সেট স্টার্টার" +"If it keeps happening, please report it at {link} with this message." = "যদি এটি বারবার ঘটতে থাকে, তবে অনুগ্রহ করে এই বার্তা সহ {link}-এ রিপোর্ট করুন।" +"If" = "যদি (If)" +"If {condition}" = "যদি {condition}" +"Ignore auto layout (absolute position)" = "অটো লেআউট উপেক্ষা করুন (অ্যাবসোলিউট পজিশন)" +"Image" = "ছবি" +"Images" = "ছবিসমূহ" +"Import" = "ইম্পোর্ট" +"Import path" = "ইম্পোর্ট পাথ" +"Import plugin from manifest" = "মেনিফেস্ট থেকে প্লাগইন ইম্পোর্ট করুন" +"Import plugin from manifest…" = "মেনিফেস্ট থেকে প্লাগইন ইম্পোর্ট করুন..." +"Import report" = "ইম্পোর্ট রিপোর্ট" +"Import variables" = "ভেরিয়েবল ইম্পোর্ট করুন" +"Imported document" = "ইম্পোর্ট করা ডকুমেন্ট" +"Imported {name}" = "{name} ইম্পোর্ট করা হয়েছে" +"Imported {n} new variables, updated {updated}" = { one = "{n}টি নতুন ভেরিয়েবল ইম্পোর্ট করা হয়েছে, {updated} আপডেট করা হয়েছে", other = "{n}টি নতুন ভেরিয়েবল ইম্পোর্ট করা হয়েছে, {updated} আপডেট করা হয়েছে" } +"Import…" = "ইম্পোর্ট..." +"In an instance, this comes from the main component" = "একটি ইনস্ট্যান্সে, এটি প্রধান কম্পোনেন্ট থেকে আসে" +"In the browser you can join sessions the desktop app hosts and files on a FigCraft server. A page opened over https reaches other computers only through wss:// links (a server behind TLS)." = "ব্রাউজারে আপনি ডেস্কটপ অ্যাপের হোস্ট করা সেশন এবং FigCraft সার্ভারের ফাইলে যুক্ত হতে পারবেন। https-এর মাধ্যমে খোলা কোনো পেজ শুধুমাত্র wss:// লিঙ্কের (TLS-এর পেছনের সার্ভার) মাধ্যমে অন্যান্য কম্পিউটারে পৌঁছায়।" +"Include bounding box" = "বাউন্ডিং বক্স অন্তর্ভুক্ত করুন" +"Include id attribute" = "id অ্যাট্রিবিউট অন্তর্ভুক্ত করুন" +"Included" = "অন্তর্ভুক্ত" +"Included in layout" = "লেআউটে অন্তর্ভুক্ত" +"Individual corners" = "পৃথক কোণা" +"Individual strokes" = "পৃথক স্ট্রোক" +"Ink" = "কালি (Ink)" +"Inner shadow" = "ইনার শ্যাডো" +"Inside" = "ভিতরে" +"Inspect" = "ইন্সপেক্ট" +"Inspect the layer under the pointer: size, colours, typography" = "পয়েন্টারের নিচের লেয়ারটি ইন্সপেক্ট করুন: আকার, রঙ, টাইপোগ্রাফি" +"Install plugin" = "প্লাগইন ইনস্টল করুন" +"Install" = "ইনস্টল করুন" +"Installed {name}" = "{name} ইনস্টল করা হয়েছে" +"Installed" = "ইনস্টল করা হয়েছে" +"Installing…" = "ইনস্টল হচ্ছে..." +"Instance" = "ইনস্ট্যান্স" +"Instance properties" = "ইনস্ট্যান্স প্রপার্টি" +"Instance swap" = "ইনস্ট্যান্স সোয়াপ" +"Instance swap (no other component)" = "ইনস্ট্যান্স সোয়াপ (অন্য কোনো কম্পোনেন্ট নেই)" +"Instances" = "ইনস্ট্যান্সসমূহ" +"Instant" = "তাৎক্ষণিক" +"Intensity" = "তীব্রতা" +"Interaction" = "ইন্টারঅ্যাকশন" +"Interactions" = "ইন্টারঅ্যাকশনসমূহ" +"Interface design and prototyping. A free, open-source, clean-room design tool written from scratch in Rust, part of the ArtCraft Crafting Apps." = "ইন্টারফেস ডিজাইন এবং প্রোটোটাইপিং। Rust-এ শূন্য থেকে তৈরি একটি ফ্রি, ওপেন-সোর্স, ক্লিন-রুম ডিজাইন টুল, যা ArtCraft Crafting Apps-এর অংশ।" +"Intersect" = "ছেদ (Intersect)" +"Intersects" = "ছেদ করে" +"Invalid color" = "অবৈধ রঙ" +"Invite link" = "আমন্ত্রণ লিংক" +"Invite link copied" = "আমন্ত্রণ লিংক কপি করা হয়েছে" +"It asks permission to:" = "এটি অনুমতি চায়:" +"Jan" = "জানুয়ারি" +"Join" = "যুক্ত হোন" +"Join code copied" = "যুক্তি কোড (Join code) কপি করা হয়েছে" +"Join code or invite link" = "যুক্ত হওয়ার কোড বা আমন্ত্রণ লিংক" +"Join live session" = "লাইভ সেশনে যুক্ত হোন" +"Join us on Discord" = "ডিসকর্ডে আমাদের সাথে যুক্ত হোন" +"Join with a code" = "কোড দিয়ে যুক্ত হোন" +"Joined" = "যুক্ত হয়েছেন" +"Joined {file}" = "{file}-এ যুক্ত হয়েছেন" +"Joining…" = "যুক্ত হওয়া হচ্ছে..." +"Join|stroke" = "জয়েন" +"Jul" = "জুলাই" +"Jun" = "জুন" +"Just now" = "এইমাত্র" +"Justify" = "উভয়মুখী সারিবদ্ধকরণ (Justify)" +"Keep appearance" = "চেহারা অপরিবর্তিত রাখুন" +"Keep appearance converts every color value to {profile} so colors look the same. Keep values leaves the numbers as they are, so colors shift." = "চেহারা অপরিবর্তিত রাখলে প্রতিটি রঙের মান {profile}-এ রূপান্তরিত হয় যাতে রঙগুলো একই দেখায়। মান অপরিবর্তিত রাখলে সংখ্যাগুলো যেমন আছে তেমনই থাকে, ফলে রঙ পরিবর্তিত হয়।" +"Keep tool selected after use" = "ব্যবহারের পরেও টুল নির্বাচিত রাখুন" +"Keep values" = "মান অপরিবর্তিত রাখুন" +"Key/Gamepad" = "কি/গেমপ্যাড" +"Key/Gamepad {key}" = "কি/গেমপ্যাড {key}" +"Keyboard shortcuts" = "কিবোর্ড শর্টকাট" +"Kits" = "কিটস" +"Landing page" = "ল্যান্ডিং পেজ" +"Language" = "ভাষা" +"Lasso" = "ল্যাসো" +"Last" = "শেষ" +"Last change: {note}" = "শেষ পরিবর্তন: {note}" +"Last commit" = "শেষ কমিট" +"Last modified" = "সর্বশেষ সংশোধিত" +"Last on top" = "প্রথমে নিচে" +"Layer blur" = "লেয়ার ব্লার" +"Layer name" = "লেয়ারের নাম" +"Layers" = "লেয়ারসমূহ" +"Layers in an instance take their position from the main component" = "ইনস্ট্যান্সের লেয়ারগুলো তাদের অবস্থান প্রধান কম্পোনেন্ট থেকে নেয়" +"Layout" = "লেআউট" +"Layout grid" = "লেআউট গ্রিড" +"Layout grid settings" = "লেআউট গ্রিড সেটিংস" +"Layout grids" = "লেআউট গ্রিডসমূহ" +"Leave session" = "সেশন ত্যাগ করুন" +"Leaves" = "ত্যাগ করে" +"Left" = "বামে" +"Left & right" = "বাম ও ডান" +"Left side" = "বাম পাশ" +"Left side width" = "বাম পাশের প্রস্থ" +"Letter case" = "লেটার কেস" +"Letter spacing" = "অক্ষরের ব্যবধান" +"Libraries" = "লাইব্রেরিসমূহ" +"Libraries in this file" = "এই ফাইলের লাইব্রেরিগুলো" +"Library updates available" = "লাইব্রেরি আপডেট উপলব্ধ আছে" +"Library: click to collapse" = "লাইব্রেরি: সংকুচিত করতে ক্লিক করুন" +"Library: click to expand" = "লাইব্রেরি: প্রসারিত করতে ক্লিক করুন" +"Library: its variables are read-only here; edit them in the library file and publish it" = "লাইব্রেরি: এর ভেরিয়েবলগুলো এখানে শুধুমাত্র পড়ার উপযোগী; লাইব্রেরি ফাইলে গিয়ে এগুলো এডিট করুন এবং প্রকাশ করুন" +"Light" = "লাইট" +"Lighten" = "লাইটেন" +"Light|glass" = "লাইট" +"Line" = "লাইন" +"Line arrow" = "লাইন তীর" +"Line delta" = "লাইন ডেল্টা" +"Line height" = "লাইনের উচ্চতা" +"Linear" = "লিনিয়ার" +"Lines" = "লাইনসমূহ" +"Lines +/−" = "লাইন +/−" +"Lines added" = "লাইন যোগ করা হয়েছে" +"Lines deleted" = "লাইন ডিলিট করা হয়েছে" +"Link" = "লিংক" +"Link copied" = "লিংক কপি করা হয়েছে" +"Link docs, code or tickets for developers." = "ডেভেলপারদের জন্য ডকুমেন্ট, কোড বা টিকিট লিংক করুন।" +"Link frames on the Prototype tab, then present the flow" = "প্রোটোটাইপ ট্যাবে ফ্রেমগুলো লিংক করুন, এরপর ফ্লো উপস্থাপন করুন" +"Link this component to its code component: instances then show your real snippet with their values." = "এই কম্পোনেন্টটিকে এর কোড কম্পোনেন্টের সাথে লিংক করুন: ইনস্ট্যান্সগুলো তখন তাদের মানসহ আপনার আসল স্নিপেট দেখাবে।" +"List spacing" = "তালিকায় ব্যবধান" +"List style" = "তালিকায় স্টাইল" +"Live session" = "লাইভ সেশন" +"Live · {people}" = "লাইভ · {people}" +"Local components" = "স্থানীয় কম্পোনেন্ট" +"Local network" = "স্থানীয় নেটওয়ার্ক" +"Local styles" = "স্থানীয় স্টাইল" +"Local variables" = "স্থানীয় ভেরিয়েবল" +"Lock/Unlock" = "লক/আনলক" +"Locked" = "লক করা" +"Lowercase" = "ছোট হাতের অক্ষর" +"Luminance" = "লুমিন্যান্স" +"Luminosity" = "উজ্জ্বলতা (Luminosity)" +"Main" = "প্রধান" +"Main component deleted" = "প্রধান কম্পোনেন্ট ডিলিট করা হয়েছে" +"Main menu" = "প্রধান মেনু" +"Make a component" = "একটি কম্পোনেন্ট তৈরি করুন" +"Make slot" = "স্লট তৈরি করুন" +"Mar" = "মার্চ" +"Margin" = "মার্জিন" +"Mark all as read" = "সব পঠিত হিসেবে চিহ্নিত করুন" +"Mark as resolved" = "সমাধানকৃত হিসেবে চিহ্নিত করুন" +"Mark as unread" = "অপঠিত হিসেবে চিহ্নিত করুন" +"Mark as unresolved" = "অমীমাংসিত হিসেবে চিহ্নিত করুন" +"Marker" = "মার্কার" +"Mask" = "মাস্ক" +"Mass · Stiffness · Damping" = "ভর · দৃঢ়তা · ড্যাম্পিং" +"Match (empty: the whole name)" = "মেলবন্ধন (খালি: সম্পূর্ণ নাম)" +"Max H" = "সর্বোচ্চ উচ্চতা" +"Max W" = "সর্বোচ্চ প্রস্থ" +"Max lines" = "সর্বোচ্চ লাইন" +"Maximize" = "বড় করুন" +"May" = "মে" +"Measure to the selection" = "নির্বাচন পর্যন্ত পরিমাপ করুন" +"Merge into main" = "প্রধান ফাইলের সাথে মার্জ করুন" +"Merge regions" = "অঞ্চলগুলো মার্জ করুন" +"Merge the branch into the main file (one undo step) and archive it" = "ব্রাঞ্চটিকে প্রধান ফাইলের সাথে মার্জ করুন (একটি আনডু ধাপ) এবং আর্কাইভ করুন" +"Merged" = "মার্জ করা হয়েছে" +"Merged PRs" = "মার্জ করা PR" +"Min H" = "সর্বনিম্ন উচ্চতা" +"Min W" = "সর্বনিম্ন প্রস্থ" +"Minimize" = "ছোট করুন" +"Mirror angle" = "মিরর কোণ" +"Mirror angle and length" = "মিরর কোণ ও দৈর্ঘ্য" +"Mirroring" = "মিররিং" +"Missing" = "অনুপস্থিত" +"Missing fonts" = "অনুপস্থিত ফন্ট" +"Missing layer" = "অনুপস্থিত লেয়ার" +"Missing style" = "অনুপস্থিত স্টাইল" +"Missing variable" = "অনুপস্থিত ভেরিয়েবল" +"Miter" = "মাইটার" +"Miter angle" = "মাইটার কোণ" +"Mixed" = "মিশ্রিত" +"Mixed fonts" = "মিশ্র ফন্ট" +"Mixed interactions: click + to replace them" = "মিশ্র ইন্টারঅ্যাকশন: প্রতিস্থাপন করতে + এ ক্লিক করুন" +"Mobile app" = "মোবাইল অ্যাপ" +"Mode" = "মোড" +"Model" = "মডেল" +"Models" = "মডেলসমূহ" +"Mono" = "মোনো" +"More" = "আরও" +"More instance actions" = "আরও ইনস্ট্যান্স অ্যাকশন" +"Mouse down" = "মাউস ডাউন" +"Mouse enter" = "মাউস এন্টার" +"Mouse leave" = "মাউস লিভ" +"Mouse up" = "মাউস আপ" +"Move along the toolbar or the layer rows" = "টুলবার বা লেয়ার সারির বরাবর সরান" +"Move" = "সরান" +"Move column left" = "কলাম বামে সরান" +"Move column right" = "কলাম ডানে সরান" +"Move down" = "নিচে সরান" +"Move in" = "ভিতরে সরান" +"Move out" = "বাইরে সরান" +"Move tools" = "সরানোর টুলস" +"Move up" = "উপরে সরান" +"Moved to {name}" = "{name}-এ সরানো হয়েছে" +"Multi" = "মাল্টি" +"Multiply" = "মাল্টিপ্লাই" +"Muted" = "মিউটেড" +"Name" = "নাম" +"Name (A–Z)" = "নাম (A–Z)" +"Named instance" = "নামকৃত ইনস্ট্যান্স" +"Navigate to" = "এতে যান" +"Nested instance" = "নেস্টেড ইনস্ট্যান্স" +"New board" = "নতুন বোর্ড" +"New design file" = "নতুন ডিজাইন ফাইল" +"Newest first" = "সবচেয়ে নতুন প্রথমে" +"Next or previous control in the focused region" = "ফোকাসড অঞ্চলের পরবর্তী বা পূর্ববর্তী কন্ট্রোল" +"Next frame" = "পরবর্তী ফ্রেম" +"Next ready for dev" = "ডেভের জন্য পরবর্তী প্রস্তুত" +"Next size option" = "পরবর্তী আকারের বিকল্প" +"No assets: no export settings, icons or images. Add export settings in the Design tab." = "কোনো অ্যাসেট নেই: কোনো এক্সপোর্ট সেটিংস, আইকন বা ছবি নেই। ডিজাইন ট্যাবে এক্সপোর্ট সেটিংস যোগ করুন।" +"No boolean variables fit here." = "এখানে কোনো বুলিয়ান ভেরিয়েবল মানানসই নয়।" +"No boolean variables yet. Create them in Local variables." = "এখনো কোনো বুলিয়ান ভেরিয়েবল নেই। স্থানীয় ভেরিয়েবলে (Local variables) এগুলো তৈরি করুন।" +"No branches. A branch is a copy of the file to try changes in, then review and merge." = "কোনো ব্রাঞ্চ নেই। ব্রাঞ্চ হলো পরিবর্তনগুলো পরীক্ষা করার জন্য ফাইলের একটি কপি, এরপর পর্যালোচনা করে মার্জ করা হয়।" +"No category" = "কোনো বিভাগ নেই" +"No changes" = "কোনো পরিবর্তন নেই" +"No color styles" = "কোনো কালার স্টাইল নেই" +"No colour variables fit here." = "এখানে কোনো রঙের ভেরিয়েবল মানানসই নয়।" +"No colour variables yet. Create them in Local variables." = "এখনো কোনো রঙের ভেরিয়েবল নেই। স্থানীয় ভেরিয়েবলে এগুলো তৈরি করুন।" +"No comments yet" = "এখনো কোনো মন্তব্য নেই" +"No compatibility warnings reported" = "কোনো সঙ্গতির সতর্কতা রিপোর্ট করা হয়নি" +"No components yet. Select layers and press {keys}." = "এখনো কোনো কম্পোনেন্ট নেই। লেয়ার নির্বাচন করুন এবং {keys} টিপুন।" +"No conflicts." = "কোনো দ্বন্দ্ব নেই।" +"No contributor data was built into this copy." = "এই কপিতে কোনো অবদানকারীর ডেটা যুক্ত করা হয়নি।" +"No device" = "কোনো ডিভাইস নেই" +"No fonts match" = "কোনো ফন্ট মেলে না" +"No files or templates match your search" = "আপনার অনুসন্ধানের সাথে কোনো ফাইল বা টেমপ্লেট মেলে না" +"No join code here (the server is older than join codes, or this address can't be put in one): share the link instead." = "এখানে কোনো যুক্ত হওয়ার কোড নেই (সার্ভারটি যুক্ত হওয়ার কোডের চেয়ে পুরনো, অথবা এই ঠিকানাকে কোডে রূপান্তর করা যায় না): পরিবর্তে লিংক শেয়ার করুন।" +"No layer changes to show." = "দেখানোর মতো কোনো লেয়ার পরিবর্তন নেই।" +"No libraries yet. Add a .figcraft file that was published as a library, or paste a team library's link." = "এখনো কোনো লাইব্রেরি নেই। লাইব্রেরি হিসেবে প্রকাশিত একটি .figcraft ফাইল যোগ করুন, অথবা টিম লাইব্রেরির লিংক পেস্ট করুন।" +"No list" = "কোনো তালিকা নেই" +"No matching comments" = "কোনো মিলছে এমন মন্তব্য নেই" +"No matching components" = "কোনো মিলছে এমন কম্পোনেন্ট নেই" +"No matching layers" = "কোনো মিলছে এমন লেয়ার নেই" +"No matching styles" = "কোনো মিলছে এমন স্টাইল নেই" +"No matching variables" = "কোনো মিলছে এমন ভেরিয়েবল নেই" +"No mirroring" = "কোনো মিররিং নেই" +"No model credits were built into this copy." = "এই কপিতে কোনো মডেল ক্রেডিট যুক্ত করা হয়নি।" +"No number variables fit here." = "এখানে কোনো নম্বরের ভেরিয়েবল মানানসই নয়।" +"No number variables yet. Create them in Local variables." = "এখনো কোনো নম্বরের ভেরিয়েবল নেই। স্থানীয় ভেরিয়েবলে এগুলো তৈরি করুন।" +"No other pages" = "অন্য কোনো পেজ নেই" +"No paints" = "কোনো পেইন্ট নেই" +"No plugins on {server}." = "{server}-এ কোনো প্লাগইন নেই।" +"No plugins yet. Import a development plugin from its figcraft-plugin.json." = "এখনো কোনো প্লাগইন নেই। এর figcraft-plugin.json থেকে একটি ডেভেলপমেন্ট প্লাগইন ইম্পোর্ট করুন।" +"No preferred components" = "কোনো পছন্দের কম্পোনেন্ট নেই" +"No preset" = "কোনো প্রিসেট নেই" +"No saved versions yet. Save a version to mark a point in your work you can come back to." = "এখনো কোনো সংরক্ষিত সংস্করণ নেই। আপনার কাজের এমন একটি পয়েন্ট চিহ্নিত করতে একটি সংস্করণ সংরক্ষণ করুন যেখানে আপনি পরে ফিরে আসতে পারেন।" +"No scrolling" = "কোনো স্ক্রোলিং নেই" +"No shortcuts match" = "কোনো শর্টকাট মেলে না" +"No status" = "কোনো স্ট্যাটাস নেই" +"No string variables fit here." = "এখানে কোনো স্ট্রিং ভেরিয়েবল মানানসই নয়।" +"No string variables yet. Create them in Local variables." = "এখনো কোনো স্ট্রিং ভেরিয়েবল নেই। স্থানীয় ভেরিয়েবলে এগুলো তৈরি করুন।" +"No styles yet." = "এখনো কোনো স্টাইল নেই।" +"No styles yet. Click + to create one from the selection." = "এখনো কোনো স্টাইল নেই। নির্বাচন থেকে একটি তৈরি করতে + এ ক্লিক করুন।" +"No templates match your search" = "আপনার অনুসন্ধানের সাথে কোনো টেমপ্লেট মেলে না" +"No text styles yet. Click + to create one from the selected text." = "এখনো কোনো টেক্সট স্টাইল নেই। নির্বাচিত টেক্সট থেকে একটি তৈরি করতে + এ ক্লিক করুন।" +"No variables fit here." = "এখানে কোনো ভেরিয়েবল মানানসই নয়।" +"No variables yet. Create them in Local variables." = "এখনো কোনো ভেরিয়েবল নেই। স্থানীয় ভেরিয়েবলে এগুলো তৈরি করুন।" +"Nobody else is here yet" = "এখানে আর কেউ নেই" +"Noise" = "নয়েজ" +"None" = "কিছুই না" +"None." = "কিছুই না।" +"Normal" = "স্বাভাবিক" +"Not bound" = "আবদ্ধ নয়" +"Not in a live session" = "লাইভ সেশনে নেই" +"Not in this font" = "এই ফন্টে নেই" +"Not properties, kept as written: {names}" = "প্রপার্টি নয়, যেমনটি লেখা হয়েছে তেমনি রাখা হয়েছে: {names}" +"Not read yet" = "এখনো পড়া হয়নি" +"Not shared" = "শেয়ার করা হয়নি" +"Note {n}" = "নোট {n}" +"Nothing changed since this was marked ready for dev." = "এটি ডেভের জন্য প্রস্তুত হিসেবে চিহ্নিত করার পর থেকে কিছু পরিবর্তিত হয়নি।" +"Nothing here yet. Set a frame's status to list it." = "এখানে এখনো কিছু নেই। তালিকাভুক্ত করতে একটি ফ্রেমের স্ট্যাটাস সেট করুন।" +"Nothing online yet. Use Share on a file to put it online and it is listed here." = "অনলাইনে এখনো কিছু নেই। কোনো ফাইল অনলাইনে দিতে শেয়ার ব্যবহার করুন এবং এটি এখানে তালিকাভুক্ত হবে।" +"Nothing to export" = "এক্সপোর্ট করার মতো কিছু নেই" +"Nov" = "নভেম্বর" +"Now" = "এখন" +"Nudge (Shift: big nudge)" = "নাজ (Shift: বড় নাজ)" +"Nudge amount" = "নাজ পরিমাণ" +"Number" = "নম্বর" +"Number ↑" = "নম্বর ↑" +"Number ↓" = "নম্বর ↓" +"Numbered list" = "সাংখ্যিক তালিকা" +"Object" = "অবজেক্ট" +"Oct" = "অক্টোবর" +"Off" = "বন্ধ" +"Offered first when filling the slot (any content is allowed)" = "স্লট পূর্ণ করার সময় প্রথমে অফার করা হয় (যেকোনো কন্টেন্ট অনুমোদিত)" +"Offered first when swapping the instance (any component is allowed)" = "ইনস্ট্যান্স সোয়াপ করার সময় প্রথমে অফার করা হয় (যেকোনো কম্পোনেন্ট অনুমোদিত)" +"Offset" = "অফসেট" +"Oldest first" = "সবচেয়ে পুরনো প্রথমে" +"On click" = "ক্লিকে" +"On drag" = "ড্র্যাগে" +"On this page" = "এই পেজে" +"One announcement in five common sizes: square, portrait, story, link and banner" = "পাঁচটি সাধারণ আকারে একটি ঘোষণা: স্কয়ার, পোর্ট্রেট, স্টোরি, লিংক এবং ব্যানার" +"Online" = "অনলাইন" +"Online files" = "অনলাইন ফাইলসমূহ" +"Online sharing is off: files are shared on the local network only." = "অনলাইন শেয়ারিং বন্ধ আছে: ফাইলগুলো শুধুমাত্র স্থানীয় নেটওয়ার্কে শেয়ার করা হচ্ছে।" +"Only the first changed layers are listed." = "শুধুমাত্র প্রথম পরিবর্তিত লেয়ারগুলো তালিকাভুক্ত করা হয়েছে।" +"Only the first {n} changed layers are listed." = "শুধুমাত্র প্রথম {n}টি পরিবর্তিত লেয়ার তালিকাভুক্ত করা হয়েছে।" +"Only your threads" = "শুধুমাত্র আপনার থ্রেড" +"Opacity" = "অস্বচ্ছতা (Opacity)" +"Opacity & blend" = "অস্বচ্ছতা ও ব্লেন্ড" +"Opacity 10%…90%, 0: 100%" = "অস্বচ্ছতা ১০%…৯০%, ০: ১০০%" +"Open" = "খুলুন" +"Open .figcraft, .svg or images" = ".figcraft, .svg বা ছবি খুলুন" +"Open branch" = "ব্রাঞ্চ খুলুন" +"Open file" = "ফাইল খুলুন" +"Open files" = "ফাইলসমূহ খুলুন" +"Open in focus view" = "ফোকাস ভিউতে খুলুন" +"Open in new window" = "নতুন উইন্ডোতে খুলুন" +"Open invite links in FigCraft" = "FigCraft-এ আমন্ত্রণ লিংক খুলুন" +"Open link" = "লিংক খুলুন" +"Open link {url}" = "লিংক খুলুন {url}" +"Open overlay" = "ওভারলে খুলুন" +"Open prototype preview" = "প্রোটোটাইপ প্রিভিউ খুলুন" +"Open variables ({count})" = "ভেরিয়েবল খুলুন ({count})" +"Opened as a new document" = "নতুন ডকুমেন্ট হিসেবে খোলা হয়েছে" +"Opened “{name}” in a new tab" = "নতুন ট্যাবে “{name}” খোলা হয়েছে" +"Opening the file…" = "ফাইল খোলা হচ্ছে..." +"Opening {n} files as new files" = { one = "নতুন ফাইল হিসেবে খোলা হচ্ছে", other = "{n}টি ফাইল নতুন ফাইল হিসেবে খোলা হচ্ছে" } +"Open|comments" = "খুলুন" +"Options" = "বিকল্পসমূহ" +"Orange" = "কমলা" +"Order" = "ক্রম" +"Other apps may not open every .fig file exported here." = "অন্যান্য অ্যাপ এখানে এক্সপোর্ট করা প্রতিটি .fig ফাইল নাও খুলতে পারে।" +"Other" = "অন্যান্য" +"Others lose access and keep their copy; the file stays on the server for you" = "অন্যরা অ্যাক্সেস হারায় এবং তাদের কপি নিজের কাছে রাখে; ফাইলটি আপনার জন্য সার্ভারেই থেকে যায়" +"Others type this under Share ▸ Join with a code" = "অন্যরা 'Share ▸ Join with a code' এর নিচে এটি টাইপ করে" +"Outline stroke" = "আউটলাইন স্ট্রোক" +"Outline text" = "আউটলাইন টেক্সট" +"Outlines" = "আউটলাইনসমূহ" +"Outside" = "বাইরে" +"Overflow" = "ওভারফ্লো" +"Overlay" = "ওভারলে" +"Overlay position" = "ওভারলে অবস্থান" +"Overridden here (the collection it extends has another value)" = "এখানে ওভাররাইড করা হয়েছে (যে সংগ্রহটি এটি প্রসারিত করে তার অন্য একটি মান রয়েছে)" +"Overrides" = "ওভাররাইডসমূহ" +"Owner token" = "ওনার টোকেন" +"Owner token copied" = "ওনার টোকেন কপি করা হয়েছে" +"PDF print options" = "PDF প্রিন্ট বিকল্প" +"PDF print settings" = "PDF প্রিন্ট সেটিংস" +"Padding" = "প্যাডিং" +"Page" = "পেজ" +"Pages" = "পেজসমূহ" +"Pages added to the document" = "ডকুমেন্টে পেজ যোগ করা হয়েছে" +"Pink" = "গোলাপি" +"Placed on the current page" = "বর্তমান পেজে রাখা হয়েছে" +"Pages: {pages}" = "পেজসমূহ: {pages}" +"Paint bucket" = "পেইন্ট বালতি" +"Pan the canvas" = "ক্যানভাস প্যান করুন" +"Pan with nothing selected (Shift: farther)" = "কিছু নির্বাচিত না করে প্যান করুন (Shift: দূরে)" +"Panels" = "প্যানেলসমূহ" +"Paper" = "কাগজ" +"Paragraph indent" = "প্যারাগ্রাফ ইনডেন্ট" +"Paragraph indent applies to left-aligned text" = "প্যারাগ্রাফ ইনডেন্ট বাম-সারিবদ্ধ টেক্সটের ক্ষেত্রে প্রযোজ্য" +"Paragraph spacing" = "প্যারাগ্রাফ ব্যবধান" +"Pass through" = "পাস থ্রু" +"Paste a link (https://…)" = "একটি লিংক পেস্ট করুন (https://…)" +"Paste a team library link (a view link)" = "একটি টিম লাইব্রেরি লিংক পেস্ট করুন (ভিউ লিংক)" +"Paste here" = "এখানে পেস্ট করুন" +"Paste to replace" = "প্রতিস্থাপন করতে পেস্ট করুন" +"Pasted design layers; {n} kinds of content were left out" = { one = "ডিজাইন লেয়ার পেস্ট করা হয়েছে; {n} ধরনের কন্টেন্ট বাদ দেওয়া হয়েছে", other = "ডিজাইন লেয়ার পেস্ট করা হয়েছে; {n} ধরনের কন্টেন্ট বাদ দেওয়া হয়েছে" } +"Pattern" = "প্যাটার্ন" +"Pattern alignment" = "প্যাটার্ন সারিবদ্ধকরণ" +"Pattern scale" = "প্যাটার্ন স্কেল" +"Pattern source" = "প্যাটার্ন উৎস" +"Pen" = "কলম" +"Pencil" = "পেন্সিল" +"People in this file" = "এই ফাইলের লোকজন" +"People join from anywhere with a code. The file is kept on {server} while you share it." = "কোডের মাধ্যমে যেকোনো স্থান থেকে লোকজন যুক্ত হতে পারে। ফাইলটি শেয়ার করার সময় {server}-এ রাখা হয়।" +"People on your network join with a code. Nothing goes through a server." = "আপনার নেটওয়ার্কের লোকজন কোডের মাধ্যমে যুক্ত হয়। কোনো সার্ভারের মাধ্যমে কিছু যায় না।" +"Phone" = "ফোন" +"Pick color" = "রঙ বাছুন" +"Pick color (I)" = "রঙ বাছুন (I)" +"Pick one font family first" = "প্রথমে একটি ফন্ট ফ্যামিলি বাছুন" +"Pick the Frame tool, then drag on the canvas or choose a device size" = "ফ্রেম টুল বেছে নিন, এরপর ক্যানভাসে ড্র্যাগ করুন অথবা একটি ডিভাইসের আকার বেছে নিন" +"Pin a property" = "একটি প্রপার্টি পিন করুন" +"Pinch" = "পিন্চ" +"Pixel grid" = "পিক্সেল গ্রিড" +"Pixel preview 1x" = "পিক্সেল প্রিভিউ 1x" +"Pixel preview 2x" = "পিক্সেল প্রিভিউ 2x" +"Place a shape or frame (its tool chosen)" = "একটি শেপ বা ফ্রেম রাখুন (এর টুল নির্বাচিত)" +"Place automatically" = "স্বয়ংক্রিয়ভাবে রাখুন" +"Place image" = "ছবি রাখুন" +"Plugin" = "প্লাগইন" +"Plugins run in FigCraft's sandbox, without files, network or clock. Install only plugins you trust." = "প্লাগইনগুলো ফাইল, নেটওয়ার্ক বা ঘড়ি ছাড়া FigCraft-এর স্যান্ডবক্সে চলে। শুধুমাত্র বিশ্বস্ত প্লাগইন ইনস্টল করুন।" +"Plugins" = "প্লাগইনসমূহ" +"Plugins & widgets" = "প্লাগইন ও উইজেট" +"Plus darker" = "প্লাস অন্ধকার" +"Plus lighter" = "প্লাস হালকা" +"Plus one" = "প্লাস ওয়ান" +"Point" = "বিন্দু" +"Points" = "বিন্দুসমূহ" +"Polygon" = "বহুভুজ" +"Polygons" = "বহুভুজসমূহ" +"Port" = "পোর্ট" +"Position" = "অবস্থান" +"Post" = "পোস্ট" +"Preferences" = "প্রেফারেন্সেস" +"Preferred" = "পছন্দসই" +"Present" = "উপস্থাপন করুন" +"Present or preview" = "উপস্থাপন বা প্রিভিউ করুন" +"Present this flow" = "এই ফ্লো উপস্থাপন করুন" +"Presentation" = "উপস্থাপন" +"Presentation slides" = "উপস্থাপন স্লাইড" +"Presenting: comments" = "উপস্থাপন করা হচ্ছে: মন্তব্য" +"Presenting: cycle the size options" = "উপস্থাপন করা হচ্ছে: আকারের বিকল্পগুলো সাইকেল করুন" +"Presenting: full screen" = "উপস্থাপন করা হচ্ছে: ফুল স্ক্রিন" +"Presenting: next frame" = "উপস্থাপন করা হচ্ছে: পরবর্তী ফ্রেম" +"Presenting: previous frame" = "উপস্থাপন করা হচ্ছে: পূর্ববর্তী ফ্রেম" +"Presenting: restart" = "উপস্থাপন করা হচ্ছে: পুনরায় শুরু" +"Preserve scroll position" = "স্ক্রোল অবস্থান সংরক্ষণ করুন" +"Presets" = "প্রিসেটসমূহ" +"Press a key…" = "একটি কি টিপুন..." +"Playground" = "প্লেগ্রাউন্ড" +"Preview" = "প্রিভিউ" +"Preview this flow" = "এই ফ্লো প্রিভিউ করুন" +"Preview this frame" = "এই ফ্রেম প্রিভিউ করুন" +"Previous frame" = "পূর্ববর্তী ফ্রেম" +"Previous ready for dev" = "ডেভের জন্য পূর্ববর্তী প্রস্তুত" +"Progressive" = "প্রোগ্রেসিভ" +"Properties" = "প্রপার্টিজ" +"Prototype" = "প্রোটোটাইপ" +"Prototype preview" = "প্রোটোটাইপ প্রিভিউ" +"Prototype settings" = "প্রোটোটাইপ সেটিংস" +"Publish" = "প্রকাশ করুন" +"Publish changes" = "পরিবর্তন প্রকাশ করুন" +"Publish to another server…" = "অন্য সার্ভারে প্রকাশ করুন..." +"Publish to server" = "সার্ভারে প্রকাশ করুন" +"Published version {v}" = "প্রকাশিত সংস্করণ {v}" +"Published version {v} to the server" = "সংস্করণ {v} সার্ভারে প্রকাশ করা হয়েছে" +"Published version {v}: save the file so other files can use it" = "প্রকাশিত সংস্করণ {v}: ফাইলটি সেভ করুন যাতে অন্যান্য ফাইল এটি ব্যবহার করতে পারে" +"Published · {people}" = "প্রকাশিত · {people}" +"Publishing to a server needs the desktop app; in the browser you can join a server's files" = "সার্ভারে প্রকাশ করতে ডেস্কটপ অ্যাপ প্রয়োজন; ব্রাউজারে আপনি সার্ভারের ফাইলে যুক্ত হতে পারবেন" +"Publishing with the server's key." = "সার্ভারের কী দিয়ে প্রকাশ করা হচ্ছে।" +"Publishing…" = "প্রকাশ করা হচ্ছে..." +"Push" = "পুশ" +"Push changes to main component" = "প্রধান কম্পোনেন্টে পরিবর্তন পুশ করুন" +"Put text on path" = "পাথে টেক্সট রাখুন" +"Question" = "প্রশ্ন" +"R" = "R" +"Radial" = "রেডিয়াল" +"Radius" = "ব্যাসার্ধ" +"Ratio" = "অনুপাত" +"Reading the library…" = "লাইব্রেরি পড়া হচ্ছে..." +"Ready for dev" = "ডেভের জন্য প্রস্তুত" +"Real name" = "আসল নাম" +"Recent" = "সাম্প্রতিক" +"Recent files" = "সাম্প্রতিক ফাইল" +"Recents" = "সাম্প্রতিকসমূহ" +"Recovered {n} unsaved files after the app quit unexpectedly: save them to keep them" = { one = "অ্যাপটি অপ্রত্যাশিতভাবে বন্ধ হয়ে যাওয়ার পর একটি অসংরক্ষিত ফাইল পুনরুদ্ধার করা হয়েছে: এটি রাখতে সেভ করুন", other = "অ্যাপটি অপ্রত্যাশিতভাবে বন্ধ হয়ে যাওয়ার পর {n}টি অসংরক্ষিত ফাইল পুনরুদ্ধার করা হয়েছে: এগুলো রাখতে সেভ করুন" } +"Rectangle" = "আয়তক্ষেত্র" +"Rectangles" = "আয়তক্ষেত্রসমূহ" +"Red" = "লাল" +"Reduce motion" = "মোশন কমান" +"Refraction" = "প্রতিসরণ" +"Regular expression" = "রেগুলার এক্সপ্রেশন" +"Remove" = "সরান" +"Remove all" = "সব সরান" +"Remove annotation" = "অ্যানোটেশন সরান" +"Remove auto layout" = "অটো লেআউট সরান" +"Remove dev resource" = "ডেভ রিসোর্স সরান" +"Remove export setting" = "এক্সপোর্ট সেটিংস সরান" +"Remove from recents" = "সাম্প্রতিক থেকে সরান" +"Remove from server" = "সার্ভার থেকে সরান" +"Remove from server…" = "সার্ভার থেকে সরান..." +"Remove guide" = "গাইড সরান" +"Remove interaction" = "ইন্টারঅ্যাকশন সরান" +"Remove library from this file" = "এই ফাইল থেকে লাইব্রেরি সরান" +"Remove min and max" = "সর্বনিম্ন এবং সর্বোচ্চ সরান" +"Remove min and max height" = "সর্বনিম্ন এবং সর্বোচ্চ উচ্চতা সরান" +"Remove min and max width" = "সর্বনিম্ন এবং সর্বোচ্চ প্রস্থ সরান" +"Remove regions" = "অঞ্চলগুলো সরান" +"Remove slot" = "স্লট সরান" +"Remove starting point" = "শুরু করার বিন্দু সরান" +"Remove stop" = "স্টপ সরান" +"Remove text from path" = "পাথ থেকে টেক্সট সরান" +"Remove the else and its actions" = "else এবং এর অ্যাকশনগুলো সরান" +"Remove this condition and its actions" = "এই শর্ত এবং এর অ্যাকশনগুলো সরান" +"Remove this file from the server? It can't be shared or opened from there again. Your copy stays open here." = "সার্ভার থেকে এই ফাইলটি সরাবেন? এটি সেখান থেকে আর শেয়ার বা খোলা যাবে না। আপনার কপিটি এখানে খোলা থাকবে।" +"Remove {name} from session" = "সেশন থেকে {name}-কে সরান" +"Remove {name} from {server}?" = "{server} থেকে {name}-কে সরাবেন?" +"Removed" = "সরে গেছে" +"Removed from the server" = "সার্ভার থেকে সরানো হয়েছে" +"Removed since it was marked" = "চিহ্নিত করার পর থেকে সরানো হয়েছে" +"Rename" = "নাম পরিবর্তন করুন" +"Rename duplicated layers" = "ডুপ্লিকেট করা লেয়ারের নাম পরিবর্তন করুন" +"Rename folder" = "ফোল্ডারের নাম পরিবর্তন করুন" +"Rename group" = "গ্রুপের নাম পরিবর্তন করুন" +"Rename layers" = "লেয়ারের নাম পরিবর্তন করুন" +"Rename mode" = "মোডের নাম পরিবর্তন করুন" +"Rename on the server" = "সার্ভারে নাম পরিবর্তন করুন" +"Rename on the server…" = "সার্ভারে নাম পরিবর্তন করুন..." +"Rename page" = "পেজের নাম পরিবর্তন করুন" +"Rename to" = "নাম পরিবর্তন করে রাখুন" +"Replace all" = "সব প্রতিস্থাপন করুন" +"Replace mixed export settings" = "মিশ্র এক্সপোর্ট সেটিংস প্রতিস্থাপন করুন" +"Replace with" = "এটি দিয়ে প্রতিস্থাপন করুন" +"Replace with a new interaction" = "একটি নতুন ইন্টারঅ্যাকশন দিয়ে প্রতিস্থাপন করুন" +"Reply" = "উত্তর দিন" +"Report" = "রিপোর্ট" +"Report this file…" = "এই ফাইলটি রিপোর্ট করুন..." +"Reset" = "রিসেট" +"Reset adjustments" = "অ্যাডজাস্টমেন্ট রিসেট করুন" +"Reset all changes" = "সব পরিবর্তন রিসেট করুন" +"Reset all overrides on this layer" = "এই লেয়ারের সব ওভাররাইড রিসেট করুন" +"Reset overrides" = "ওভাররাইড রিসেট করুন" +"Reset slot" = "স্লট রিসেট করুন" +"Reset this override" = "এই ওভাররাইড রিসেট করুন" +"Reset to style" = "স্টাইলে রিসেট করুন" +"Reset to the inherited value" = "উত্তরাধিকারসূত্রে প্রাপ্ত মানে রিসেট করুন" +"Resize by the nudge amount" = "নাজ পরিমাণ অনুযায়ী আকার পরিবর্তন করুন" +"Resize frames to the device's screen (or the window without a device): their constraints and auto layout respond" = "ডিভাইসের স্ক্রিনে (বা ডিভাইস ছাড়া উইন্ডোতে) ফ্রেমের আকার পরিবর্তন করুন: তাদের সীমাবদ্ধতা এবং অটো লেআউট সাড়া দেয়" +"Resize to fit" = "ফিট করতে আকার পরিবর্তন করুন" +"Resizing" = "আকার পরিবর্তন করা হচ্ছে" +"Resolve" = "সমাধান করুন" +"Resolve change" = "পরিবর্তন সমাধান করুন" +"Resolved" = "সমাধান করা হয়েছে" +"Resources" = "রিসোর্সসমূহ" +"Responsive" = "রেসপনসিভ" +"Restart" = "পুনরায় শুরু করুন" +"Restore" = "পুনরুদ্ধার করুন" +"Restore main component" = "প্রধান কম্পোনেন্ট পুনরুদ্ধার করুন" +"Restore this version" = "এই সংস্করণ পুনরুদ্ধার করুন" +"Restored “{name}”" = "“{name}” পুনরুদ্ধার করা হয়েছে" +"Reverse the order" = "ক্রম উল্টে দিন" +"Reversed triangle" = "উল্টানো ত্রিভুজ" +"Review and merge" = "পর্যালোচনা ও মার্জ করুন" +"Ribbon" = "রিবন" +"Right" = "ডানে" +"Right side" = "ডান পাশ" +"Right side width" = "ডান পাশের প্রস্থ" +"Rotate 90°" = "৯০° ঘোরান" +"Rotate 90° right" = "ডানে ৯০° ঘোরান" +"Rotate to landscape" = "ল্যান্ডস্কেপে ঘোরান" +"Rotate to portrait" = "পোর্ট্রেটে ঘোরান" +"Rotation" = "ঘূর্ণন" +"Rotation jitter" = "ঘূর্ণন জিটার" +"Round" = "গোল" +"Rounded" = "গোল করা" +"Rounded rectangle" = "গোলাকার আয়তক্ষেত্র" +"Row" = "সারি" +"Row gap" = "সারির গ্যাপ" +"Row sizes" = "সারির আকার" +"Row span" = "সারি স্প্যান" +"Rows" = "সারিগুলো" +"Rows {count}" = "সারি {count}" +"Rulers" = "রুলার" +"Run" = "চালান" +"Run {label}" = "চালান {label}" +"Save files automatically" = "স্বয়ংক্রিয়ভাবে ফাইল সেভ করুন" +"Search templates" = "টেমপ্লেট খুঁজুন" +"See all templates" = "সব টেমপ্লেট দেখুন" +"Select layers and they space themselves as their content changes" = "লেয়ার নির্বাচন করুন এবং তাদের কন্টেন্ট পরিবর্তিত হওয়ার সাথে সাথে তারা নিজেদের মধ্যে দূরত্ব বজায় রাখবে" +"SHA-256: {sha}…" = "SHA-256: {sha}…" +"Same as layer" = "লেয়ারের মতোই" +"Saturation" = "স্যাচুরেশন" +"Save" = "সেভ করুন" +"Save as" = "এভাবে সেভ করুন" +"Save as preset…" = "প্রিসেট হিসেবে সেভ করুন..." +"Save version {key}" = "সংস্করণ সেভ করুন {key}" +"Saved" = "সেভ করা হয়েছে" +"Saved {name}" = "সেভ করা হয়েছে {name}" +"Say something" = "কিছু বলুন" +"Scale" = "স্কেল" +"Scatter" = "স্ক্যাটার" +"Scoping: where pickers offer it" = "স্কোপিং: যেখানে পিকার এটি অফার করে" +"Screen" = "স্ক্রিন" +"Scroll behavior" = "স্ক্রোল আচরণ" +"Scroll to" = "স্ক্রোল করে যান" +"Scroll to layer" = "লেয়ারে স্ক্রোল করুন" +"Scroll with parent" = "প্যারেন্টের সাথে স্ক্রোল করুন" +"Search plugins" = "প্লাগইন খুঁজুন" +"Search" = "অনুসন্ধান" +"Search actions, layers and tools" = "অ্যাকশন, লেয়ার এবং টুল খুঁজুন" +"Search assets" = "অ্যাসেট খুঁজুন" +"Search comments" = "মন্তব্য খুঁজুন" +"Search files and templates" = "ফাইল এবং টেমপ্লেট খুঁজুন" +"Search results" = "অনুসন্ধানের ফলাফল" +"Search fonts" = "ফন্ট খুঁজুন" +"Search layers" = "লেয়ার খুঁজুন" +"Search shortcuts" = "শর্টকাট খুঁজুন" +"Section" = "সেকশন" +"Section color" = "সেকশনের রঙ" +"Sections" = "সেকশনসমূহ" +"Select a frame and add a starting point to present it as a flow." = "একটি ফ্রেম নির্বাচন করুন এবং ফ্লো হিসেবে উপস্থাপন করতে একটি শুরুর বিন্দু যোগ করুন।" +"Select a frame to preview it" = "প্রিভিউ করতে একটি ফ্রেম নির্বাচন করুন" +"Select a layer to see its properties and code" = "এর প্রপার্টি এবং কোড দেখতে একটি লেয়ার নির্বাচন করুন" +"Select all" = "সব নির্বাচন করুন" +"Select all with same" = "একই রকম সব নির্বাচন করুন" +"Select layer" = "লেয়ার নির্বাচন করুন" +"Select layers to apply the style to" = "স্টাইল প্রয়োগ করার জন্য লেয়ার নির্বাচন করুন" +"Select layers with this color" = "এই রঙের লেয়ারগুলো নির্বাচন করুন" +"Select overlapping shapes" = "overlapping শেপগুলো নির্বাচন করুন" +"Select points to edit their position, radius and mirroring" = "অবস্থান, ব্যাসার্ধ এবং মিররিং এডিট করতে পয়েন্ট নির্বাচন করুন" +"Select slot" = "স্লট নির্বাচন করুন" +"Selection colors" = "নির্বাচনের রঙ" +"Selection" = "নির্বাচন" +"Selection box, {w} by {h} at {x}, {y}: arrows move it, Shift+arrows or {cmd}+arrows size it, Enter selects, Escape cancels" = "নির্বাচন বাক্স, {w} × {h}, অবস্থান {x}, {y}: অ্যারো কি দিয়ে সরানো যায়, Shift+arrows বা {cmd}+arrows দিয়ে আকার পরিবর্তন করা যায়, Enter দিয়ে নির্বাচন হয়, Escape দিয়ে বাতিল হয়" +"Selection box: arrows move it, Shift+arrows or {cmd}+arrows size it, Enter selects, Escape cancels" = "নির্বাচন বাক্স: অ্যারো কি দিয়ে সরানো যায়, Shift+arrows বা {cmd}+arrows দিয়ে আকার পরিবর্তন করা যায়, Enter দিয়ে নির্বাচন হয়, Escape দিয়ে বাতিল হয়" +"Send backward" = "পেছনে পাঠান" +"Send report" = "রিপোর্ট পাঠান" +"Send to back" = "সবার পেছনে পাঠান" +"Sep" = "সেপ্টেম্বর" +"Server address (ws://host:47900/)" = "সার্ভার ঠিকানা (ws://host:47900/)" +"Server address" = "সার্ভারের ঠিকানা" +"Server for online sharing" = "অনলাইন শেয়ারিংয়ের সার্ভার" +"Set as default" = "ডিফল্ট হিসেবে সেট করুন" +"Set variable" = "ভেরিয়েবল সেট করুন" +"Set variable mode" = "ভেরিয়েবল মোড সেট করুন" +"Set variable {name}" = "ভেরিয়েবল সেট করুন {name}" +"Set {name} to {value}" = "{name}-এর মান {value} সেট করুন" +"Shadows" = "ছায়াসমূহ (Shadows)" +"Shape" = "শেপ" +"Shape builder" = "শেপ বিল্ডার" +"Shape tools" = "শেপ টুলস" +"Shapes" = "আকৃতি" +"Share" = "শেয়ার" +"Share this file" = "এই ফাইলটি শেয়ার করুন" +"Share with a code" = "কোড দিয়ে শেয়ার করুন" +"Share: live session, export" = "শেয়ার: লাইভ সেশন, এক্সপোর্ট" +"Shared" = "শেয়ার করা হয়েছে" +"Shared: copy its code from the file's menu" = "শেয়ার করা হয়েছে: ফাইলের মেনু থেকে এর কোড কপি করুন" +"Sharing a file needs the desktop app; here you can join one." = "ফাইল শেয়ার করতে ডেস্কটপ অ্যাপ প্রয়োজন; এখানে আপনি একটিতে যুক্ত হতে পারবেন।" +"Sharing on the local network (online sharing is off: no server is set in Preferences)." = "স্থানীয় নেটওয়ার্কে শেয়ার করা হচ্ছে (অনলাইন শেয়ারিং বন্ধ আছে: প্রেফারেন্সেসে কোনো সার্ভার সেট করা নেই)।" +"Sharing on your network · {people}" = "আপনার নেটওয়ার্কে শেয়ার করা হচ্ছে · {people}" +"Sharing online · {people}" = "অনলাইনে শেয়ার করা হচ্ছে · {people}" +"Sharing stopped: the file stays on the server, open only to you" = "শেয়ারিং বন্ধ করা হয়েছে: ফাইলটি সার্ভারেই আছে, শুধুমাত্র আপনার জন্য খোলা" +"Show" = "দেখান" +"Show all templates" = "সব টেমপ্লেট দেখান" +"Show as grid" = "গ্রিড হিসেবে দেখান" +"Show as list" = "তালিকা হিসেবে দেখান" +"Show autosaves" = "স্বয়ংক্রিয় সেভ দেখান" +"Show behind transparent areas" = "স্বচ্ছ এলাকার পেছনে দেখান" +"Show comments (Shift+C)" = "মন্তব্য দেখান (Shift+C)" +"Show device frame" = "ডিভাইস ফ্রেম দেখান" +"Show dimensions on objects" = "অবজেক্টে মাত্রা (dimensions) দেখান" +"Show hotspot hints" = "হটস্পট ইঙ্গিত দেখান" +"Show in Explorer" = "এক্সপ্লোরারে দেখান" +"Show in Finder" = "ফাইন্ডারে দেখান" +"Show in folder" = "ফোল্ডারে দেখান" +"Show only this on the canvas" = "ক্যানভাসে শুধুমাত্র এটি দেখান" +"Show open" = "খোলা দেখান" +"Show section contents" = "সেকশনের কন্টেন্ট দেখান" +"Show this layer" = "এই লেয়ারটি দেখান" +"Show usage" = "ব্যবহার দেখান" +"Show/Hide" = "দেখান/লুকান" +"Show/Hide UI" = "UI দেখান/লুকান" +"Side by side" = "পাশাপাশি" +"Sidebar" = "সাইডবার" +"Simplify stroke" = "স্ট্রোক সরল করুন" +"Size" = "আকার" +"Size jitter" = "আকার জিটার" +"Skip ink" = "কালি বাদ দিন" +"Slashed zero" = "স্ল্যাশড জিরো" +"Slice" = "স্লাইস" +"Slices" = "স্লাইসসমূহ" +"Slide in" = "স্লাইড ইন" +"Slide out" = "স্লাইড আউট" +"Slot" = "স্লট" +"Slot content" = "স্লট কন্টেন্ট" +"Small capitals" = "ছোট বড় হাতের অক্ষর" +"Small caps" = "স্মল ক্যাপস" +"Smart animate" = "স্মার্ট অ্যানিমেট" +"Snap to pixel grid" = "পিক্সেল গ্রিডে স্ন্যাপ করুন" +"Social and slides" = "সোশ্যাল ও স্লাইড" +"Social media" = "সোশ্যাল মিডিয়া" +"Social post sizes" = "সোশ্যাল পোস্টের আকার" +"Soft light" = "সফট লাইট" +"Solid" = "সলিড" +"Some variants have the same property values: give each one a unique combination" = "কিছু ভ্যারিয়েন্টের প্রপার্টি মান একই: প্রতিটিকে একটি অনন্য কম্বিনেশন দিন" +"Sort" = "সাজান" +"Sort by" = "যেভাবে সাজাবেন" +"Sort by date" = "তারিখ অনুযায়ী সাজান" +"Sort by page" = "পেজ অনুযায়ী সাজান" +"Sort by {column}" = "{column} অনুযায়ী সাজান" +"Space around" = "চারপাশে স্পেস" +"Space between" = "মাঝখানে স্পেস" +"Space drag" = "স্পেস ড্র্যাগ" +"Space evenly" = "সমানভাবে স্পেস" +"Space+drag" = "Space+ড্র্যাগ" +"Spacing" = "ব্যবধান" +"Spam, stolen work, abuse…" = "স্প্যাম, চুরি করা কাজ, অপব্যবহার..." +"Speech bubble" = "কথার বুদবুদ" +"Splay" = "সপ্লে" +"Spotlight me" = "আমাকে স্পটলাইট করুন" +"Spread" = "স্প্রেড" +"Square" = "বর্গক্ষেত্র" +"Stamp" = "স্ট্যাম্প" +"Stamps" = "স্ট্যাম্পগুলি" +"Standard" = "স্ট্যান্ডার্ড" +"Standard ligatures" = "স্ট্যান্ডার্ড লিগেচার" +"Star" = "তারকা" +"Stars" = "তারকাসমূহ" +"Start" = "শুরু" +"Start ascending from / stop descending at" = "এখান থেকে ওঠা শুরু করুন / এখানে নামা বন্ধ করুন" +"Start from a blank canvas" = "খালি ক্যানভাস থেকে শুরু করুন" +"Start from a template" = "টেমপ্লেট থেকে শুরু করুন" +"Start on path" = "পাথ থেকে শুরু করুন" +"Start point" = "শুরুর বিন্দু" +"Start sharing" = "শেয়ার করা শুরু করুন" +"Start sharing with the Share button, then pass on the code it gives" = "শেয়ার বোতাম দিয়ে শেয়ার করা শুরু করুন, তারপর এটি যে কোড দেয় তা দিন" +"Starting to share…" = "শেয়ার করা শুরু হচ্ছে..." +"Status" = "স্ট্যাটাস" +"Sticky" = "স্টিকি" +"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" = "স্ট্রিং" +"Stroke" = "স্ট্রোক" +"Stroke style" = "স্ট্রোক স্টাইল" +"Stroke weight" = "স্ট্রোকের ওজন" +"Strokes" = "স্ট্রোকসমূহ" +"Style" = "স্টাইল" +"Styles" = "স্টাইলসমূহ" +"Stylistic set 1" = "স্টাইলিস্টিক সেট ১" +"Stylistic set 2" = "স্টাইলিস্টিক সেট ২" +"Stylistic set 3" = "স্টাইলিস্টিক সেট ৩" +"Stylistic set 4" = "স্টাইলিস্টিক সেট ৪" +"Stylistic set 5" = "স্টাইলিস্টিক সেট ৫" +"Subtract" = "বিয়োগ করুন" +"Subtracts" = "বিয়োগ করে" +"Suffix" = "সাফিক্স" +"Suspend snapping while dragging" = "ড্র্যাগ করার সময় স্ন্যাপিং স্থগিত রাখুন" +"Swap instance" = "ইনস্ট্যান্স সোয়াপ করুন" +"Swap library: use another library's assets of the same names" = "লাইব্রেরি সোয়াপ করুন: একই নামের অন্য লাইব্রেরির অ্যাসেট ব্যবহার করুন" +"Swap orientation" = "ভিত্তিক বিন্যাস (orientation) সোয়াপ করুন" +"Swap overlay" = "ওভারলে সোয়াপ করুন" +"Swap to {name}" = "{name}-এ সোয়াপ করুন" +"Swap width and height" = "প্রস্থ ও উচ্চতা সোয়াপ করুন" +"Swapped instance" = "সোয়াপ করা ইনস্ট্যান্স" +"Swapped {n} assets" = { one = "{n}টি অ্যাসেট সোয়াপ করা হয়েছে", other = "{n}টি অ্যাসেট সোয়াপ করা হয়েছে" } +"Swapped {n} assets; {missed} had no match and still use the old library" = { one = "{n}টি অ্যাসেট সোয়াপ করা হয়েছে; {missed}-এর কোনো মিল পাওয়া যায়নি এবং এখনো পুরোনো লাইব্রেরি ব্যবহার করছে", other = "{n}টি অ্যাসেট সোয়াপ করা হয়েছে; {missed}-এর কোনো মিল পাওয়া যায়নি এবং এখনো পুরোনো লাইব্রেরি ব্যবহার করছে" } +"Sweep" = "সুইপ" +"System" = "সিস্টেম" +"Tab / Shift+Tab" = "Tab / Shift+Tab" +"Table" = "টেবিল" +"Tablet" = "ট্যাবলেট" +"Tabular numbers" = "টাবুলার সংখ্যা" +"Take the values from the first selected layer" = "প্রথমে নির্বাচিত লেয়ার থেকে মানগুলো নিন" +"Taper" = "ট্যাপার" +"Taper both ends" = "উভয় প্রান্ত ট্যাপার করুন" +"Tell the server's operator what is wrong with it" = "সার্ভারের অপারেটরকে জানান এতে কী সমস্যা রয়েছে" +"Temperature" = "তাপমাত্রা" +"Template" = "টেমপ্লেট" +"Templates" = "টেমপ্লেটসমূহ" +"Terms" = "শর্তাবলী" +"Text" = "টেক্সট" +"Text align" = "টেক্সট সারিবদ্ধকরণ" +"Text content" = "টেক্সট কন্টেন্ট" +"Text size" = "লেখার আকার" +"Text style" = "টেক্সট স্টাইল" +"Text styles" = "টেক্সট স্টাইলসমূহ" +"Texts" = "টেক্সটসমূহ" +"Texture" = "টেক্সচার" +"The canvas previews colors in sRGB; exported PNG, JPG and WebP files are tagged Display P3." = "ক্যানভাস sRGB-তে রঙ প্রিভিউ করে; এক্সপোর্ট করা PNG, JPG এবং WebP ফাইলে Display P3 ট্যাগ করা থাকে।" +"The destination document is unavailable. Nothing was imported." = "গন্তব্য ডকুমেন্ট অনুপলব্ধ। কিছু ইম্পোর্ট করা হয়নি।" +"The destination document was closed. Nothing was imported." = "গন্তব্য ডকুমেন্ট বন্ধ করা হয়েছিল। কিছু ইম্পোর্ট করা হয়নি।" +"The destination page was removed. Nothing was imported." = "গন্তব্য পেজ সরানো হয়েছে। কিছু ইম্পোর্ট করা হয়নি।" +"The file is deleted from the server: nobody can open or join it from there again, and this can't be undone. Copies that are open stay with whoever has them." = "ফাইলটি সার্ভার থেকে ডিলিট করা হয়েছে: কেউ আর সেখান থেকে এটি খুলতে বা যুক্ত হতে পারবে না, এবং এটি আর ফেরানো যাবে না। যে কপিগুলো খোলা আছে তা যার যার কাছে আছে তার সাথেই থাকবে।" +"The file now holds another library" = "ফাইলটিতে এখন অন্য একটি লাইব্রেরি রয়েছে" +"The file now holds another library (\"{name}\")" = "ফাইলটিতে এখন অন্য একটি লাইব্রেরি রয়েছে (\"{name}\")" +"The files you shared or published online from this computer. They stay on their server until you remove them." = "এই কম্পিউটার থেকে আপনি যে ফাইলগুলো অনলাইনে শেয়ার বা প্রকাশ করেছেন। আপনি না সরানো পর্যন্ত সেগুলো তাদের সার্ভারেই থাকে।" +"The link carries the session's secret: share it only with people you invite." = "লিংকটি সেশনের গোপনীয়তা বহন করে: শুধুমাত্র আপনার আমন্ত্রিত লোকদের সাথেই এটি শেয়ার করুন।" +"The name in your online files and on the server. The document inside keeps its own name." = "আপনার অনলাইন ফাইল এবং সার্ভারে থাকা নাম। ভেতরের ডকুমেন্টটি তার নিজস্ব নাম বজায় রাখে।" +"The selection uses several text styles: click to apply one" = "নির্বাচনে একাধিক টেক্সট স্টাইল ব্যবহার করা হয়েছে: একটি প্রয়োগ করতে ক্লিক করুন" +"The server deletes this file if nobody opens it before {date}." = "{date}-এর আগে কেউ না খুললে সার্ভার এই ফাইলটি ডিলিট করে দেবে।" +"The whole file" = "সম্পূর্ণ ফাইল" +"Theme" = "থিম" +"There's no destination for that action" = "সেই অ্যাকশনের জন্য কোনো গন্তব্য নেই" +"Thickness" = "পুরুত্ব" +"This branch can't be reviewed: {e}" = "এই ব্রাঞ্চটি পর্যালোচনা করা যাচ্ছে না: {e}" +"This branch was merged or archived." = "এই ব্রাঞ্চটি মার্জ বা আর্কাইভ করা হয়েছে।" +"This computer only" = "শুধুমাত্র এই কম্পিউটার" +"This computer remembers the owner token, so the file can be managed from the home screen's Online list. Copy it to manage the file from somewhere else: it is a password for this file and won't be shown again." = "এই কম্পিউটার ওনার টোকেন মনে রাখে, তাই হোম স্ক্রিনের অনলাইন তালিকা থেকে ফাইলটি পরিচালনা করা যেতে পারে। অন্য কোথাও থেকে ফাইলটি পরিচালনা করতে এটি কপি করুন: এটি এই ফাইলের জন্য একটি পাসওয়ার্ড এবং আর কখনো দেখানো হবে না।" +"This condition is edited as an expression" = "এই শর্তটি এক্সপ্রেশন হিসেবে এডিট করা হয়েছে" +"This file" = "এই ফাইল" +"This file has unsaved changes" = "এই ফাইলে সেভ করা হয়নি এমন পরিবর্তন রয়েছে" +"This file uses fonts that aren't installed on this computer. Their text is drawn in a fallback font until you install them." = "এই ফাইলে এমন ফন্ট ব্যবহার করা হয়েছে যা এই কম্পিউটারে ইনস্টল করা নেই। আপনি ইনস্টল না করা পর্যন্ত সেগুলোর টেক্সট একটি ফলব্যাক ফন্টে দেখানো হয়।" +"This file is on {server}, open only to you. Share it to let others in with a new code." = "এই ফাইলটি {server}-এ আছে, শুধুমাত্র আপনার জন্য খোলা। নতুন কোড দিয়ে অন্যদের প্রবেশ করাতে এটি শেয়ার করুন।" +"This frame is new on the branch." = "এই ফ্রেমটি ব্রাঞ্চে নতুন।" +"This frame isn't on the branch." = "এই ফ্রেমটি ব্রাঞ্চে নেই।" +"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" = "টাইল টাইপ" +"Tint" = "টিন্ট" +"Title" = "শিরোনাম" +"Title case" = "টাইটেল কেস" +"To share this library with your team, publish the file to a server (Share ▸ Publish to server…), then publish the library there." = "আপনার টিমের সাথে এই লাইব্রেরি শেয়ার করতে, ফাইলটি একটি সার্ভারে প্রকাশ করুন (Share ▸ Publish to server…), এরপর সেখানে লাইব্রেরি প্রকাশ করুন।" +"Toggle" = "টগল" +"Toggle light/dark theme" = "লাইট/ডার্ক থিম টগল করুন" +"Toggle visibility" = "দৃশ্যমানতা টগল করুন" +"Tools" = "টুলস" +"Top" = "উপরে" +"Top & bottom" = "উপরে ও নিচে" +"Top left radius" = "ওপরের বাম ব্যাসার্ধ" +"Top right radius" = "ওপরের ডান ব্যাসার্ধ" +"Transform" = "ট্রান্সফর্ম" +"Triangle" = "ত্রিভুজ" +"Triangle arrow" = "ত্রিভুজ তীর" +"Trigger" = "ট্রিগার" +"True" = "সত্য (True)" +"Truncate text" = "টেক্সট ট্রাঙ্কেট করুন" +"Truncate with an ellipsis" = "উপবৃত্ত (ellipsis) দিয়ে ট্রাঙ্কেট করুন" +"Try again" = "আবার চেষ্টা করুন" +"Try another search or filter." = "অন্য অনুসন্ধান বা ফিল্টার ব্যবহার করে দেখুন।" +"Try another search, or browse templates." = "অন্য অনুসন্ধান ব্যবহার করুন, অথবা টেমপ্লেট ব্রাউজ করুন।" +"Try another word, or look at every kind." = "অন্য শব্দ ব্যবহার করুন, অথবা সব ধরণ দেখুন।" +"Turn a layer into a component, reuse it, and change every copy at once" = "একটি লেয়ারকে কম্পোনেন্টে রূপান্তর করুন, এটি পুনরায় ব্যবহার করুন এবং এক সাথে সব কপি পরিবর্তন করুন" +"Two digits" = "দুটি অঙ্ক" +"Type" = "ধরন (Type)" +"Type settings" = "টাইপ সেটিংস" +"Type the join code you were given, or paste an invite link." = "আপনাকে দেওয়া যুক্ত হওয়ার কোডটি টাইপ করুন, অথবা একটি আমন্ত্রণ লিংক পেস্ট করুন।" +"Typography" = "টাইপোগ্রাফি" +"Underline" = "আন্ডারলাইন" +"Undo takes back only your own changes; what others changed since stays." = "আনডু কেবল আপনার নিজস্ব পরিবর্তনগুলো ফিরিয়ে নেয়; এর মধ্যে অন্যরা যা পরিবর্তন করেছে তা থেকেই যায়।" +"Ungroup" = "আনগ্রুপ" +"Uniform" = "ইউনিফর্ম" +"Union" = "ইউনিয়ন" +"Unions" = "ইউনিয়নসমূহ" +"Unknown — treated as sRGB" = "অজানা — sRGB হিসেবে গণ্য করা হয়েছে" +"Unread" = "অপঠিত" +"Unresolve" = "অমীমাংসিত করুন" +"Unsaved changes" = "সংরক্ষিত হয়নি এমন পরিবর্তন" +"Unsaved changes in {names}. 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" = "উপরে" +"Update plugin" = "প্লাগইন আপডেট করুন" +"Update" = "আপডেট" +"Update from main" = "প্রধান (main) থেকে আপডেট করুন" +"Update from selection" = "নির্বাচন থেকে আপডেট করুন" +"Updated {components} and {styles}" = "{components} এবং {styles} আপডেট করা হয়েছে" +"Updated {name}" = "{name} আপডেট করা হয়েছে" +"Updating the graphics driver often helps. To use OpenGL only, start FigCraft from a Command Prompt with:" = "গ্রাফিক্স ড্রাইভার আপডেট করা প্রায়ই সহায়ক হয়। শুধুমাত্র OpenGL ব্যবহার করতে, কমান্ড প্রম্পট থেকে FigCraft শুরু করুন:" +"Updating the graphics driver often helps. WGPU_BACKEND (vulkan, gl, metal) names another graphics API to try." = "গ্রাফিক্স ড্রাইভার আপডেট করা প্রায়ই সহায়ক হয়। WGPU_BACKEND (vulkan, gl, metal) চেষ্টা করার জন্য অন্য একটি গ্রাফিক্স API এর নাম বলে।" +"Uploads this file to a FigCraft server and keeps this tab in sync with it. You get a link to edit and a link to view." = "এই ফাইলটি একটি FigCraft সার্ভারে আপলোড করে এবং এই ট্যাবটিকে এর সাথে সিঙ্ক করে রাখে। আপনি এডিট করার জন্য একটি লিংক এবং দেখার জন্য একটি লিংক পান।" +"Uppercase" = "বড় হাতের অক্ষর" +"Use a fixed value" = "একটি নির্দিষ্ট মান ব্যবহার করুন" +"Use an expression, e.g. count + 1" = "একটি এক্সপ্রেশন ব্যবহার করুন, যেমন count + 1" +"Use as mask" = "মাস্ক হিসেবে ব্যবহার করুন" +"Use auto layout" = "অটো লেআউট ব্যবহার করুন" +"Use number keys for opacity" = "অস্বচ্ছতার জন্য নম্বর কি ব্যবহার করুন" +"Use the .figcraft or .fig extension." = ".figcraft বা .fig এক্সটেনশন ব্যবহার করুন।" +"Use the layer's fill" = "লেয়ারের ফিল ব্যবহার করুন" +"Used in {files} · {instances}" = "{files} · {instances}-এ ব্যবহৃত" +"Username" = "ব্যবহারকারী নাম" +"Value" = "মান" +"Uses variables: each layer shows them in its own mode" = "ভেরিয়েবল ব্যবহার করে: প্রতিটি লেয়ার তাদের নিজস্ব মোডে সেগুলো দেখায়" +"Variable" = "ভেরিয়েবল" +"Variable collections" = "ভেরিয়েবল সংগ্রহসমূহ" +"Variable modes" = "ভেরিয়েবল মোড" +"Variable width" = "পরিবর্তনশীল প্রস্থ (Variable width)" +"Variables" = "ভেরিয়েবলসমূহ" +"Variables JSON…" = "ভেরিয়েবল JSON..." +"Variables store reusable values (colours, numbers, strings, booleans) with modes such as Light and Dark." = "ভেরিয়েবলগুলো লাইট এবং ডার্কের মতো মোডের সাথে পুনঃব্যবহারযোগ্য মান (রঙ, নম্বর, স্ট্রিং, বুলিয়ান) সংরক্ষণ করে।" +"Variant" = "ভ্যারিয়েন্ট" +"Vector" = "ভেক্টর" +"Vectors" = "ভেক্টরসমূহ" +"Version" = "সংস্করণ" +"Version history" = "সংস্করণ ইতিহাস" +"Version {version}" = "সংস্করণ {version}" +"Version {v} · {components} · {styles} · {variables}" = "সংস্করণ {v} · {components} · {styles} · {variables}" +"Vertical" = "উল্লম্ব" +"Vertical align" = "উল্লম্ব সারিবদ্ধকরণ" +"Vertical hex" = "উল্লম্ব হেক্স" +"Vertical padding" = "উল্লম্ব প্যাডিং" +"Vertical spacing" = "উল্লম্ব ব্যবধান" +"Vertical trim" = "উল্লম্ব ট্রিম" +"View" = "ভিউ" +"View link" = "ভিউ লিংক" +"View link copied" = "ভিউ লিংক কপি করা হয়েছে" +"View only" = "শুধুমাত্র দেখার উপযোগী" +"View only: you see every change as it happens but can't edit. Leave to keep a copy you can edit." = "শুধুমাত্র দেখার উপযোগী: আপনি প্রতিটি পরিবর্তন ঘটার সাথে সাথে দেখতে পান কিন্তু এডিট করতে পারেন না। আপনি এডিট করতে পারেন এমন একটি কপি রাখতে ত্যাগ করুন।" +"Viewing “{name}”" = "“{name}” দেখা হচ্ছে" +"Violet" = "বেগুনি" +"Visibility" = "দৃশ্যমানতা" +"Visible" = "দৃশ্যমান" +"Vivid" = "ভিভিড" +"WGPU_BACKEND is set and names the graphics API to use; unsetting it lets FigCraft choose." = "WGPU_BACKEND সেট করা আছে এবং ব্যবহারের জন্য গ্রাফিক্স API-এর নাম দেয়; এটি আনসেট করলে FigCraft কে বেছে নিতে দেয়।" +"Warm" = "উষ্ণ" +"Watch" = "ওয়াচ" +"Wavy" = "ঢেউ খেলানো" +"Web" = "ওয়েব" +"Websites" = "ওয়েবসাইটসমূহ" +"WebSocket URL" = "WebSocket URL" +"WebSocket URL copied" = "WebSocket URL কপি করা হয়েছে" +"Wedge" = "ওয়েজ" +"Weight" = "ওজন" +"What you try here changes this preview and the code below, not the file" = "আপনি এখানে যা চেষ্টা করেন তা ফাইলটিকে নয়, কেবল এই প্রিভিউ এবং নিচের কোডটিকে পরিবর্তন করে" +"What changed since it was marked ready for dev" = "এটি ডেভের জন্য প্রস্তুত হিসেবে চিহ্নিত করার পর থেকে কী পরিবর্তিত হয়েছে" +"What is wrong with this file? The server's operator gets your report." = "এই ফাইলে কী সমস্যা রয়েছে? সার্ভারের অপারেটর আপনার রিপোর্ট পান।" +"What it's for" = "এটি কি জন্য" +"What this changed from isn't known: it changed before FigCraft kept a copy of ready-for-dev frames. Resolve the change (or mark it ready again) to compare from now on." = "এটি কি থেকে পরিবর্তিত হয়েছে তা অজানা: FigCraft ডেভের জন্য প্রস্তুত ফ্রেমের একটি কপি রাখার আগেই এটি পরিবর্তিত হয়েছে। এখন থেকে তুলনা করতে পরিবর্তনটি সমাধান করুন (বা এটি আবার প্রস্তুত হিসেবে চিহ্নিত করুন)।" +"While hovering" = "হোভার করার সময়" +"While pressing" = "চেপে ধরে রাখার সময়" +"White" = "সাদা" +"Who runs it, its terms and how long it keeps files" = "এটি কে পরিচালনা করে, এর শর্তাবলী এবং এটি কতদিন ফাইল রাখে" +"Why it changed" = "কেন এটি পরিবর্তিত হয়েছে" +"Widget" = "উইজেট" +"Widgets" = "উইজেটসমূহ" +"Width" = "প্রস্থ" +"Width point" = "প্রস্থ বিন্দু" +"Width profile" = "প্রস্থ প্রোফাইল" +"Wireframe kit" = "ওয়্যারফ্রেম কিট" +"With the layers' export settings" = "লেয়ারের এক্সপোর্ট সেটিংস সহ" +"Wrap" = "র‍্যাপ" +"Wrap in new section" = "নতুন সেকশনে র‍্যাপ করুন" +"Yellow" = "হলুদ" +"Yesterday" = "গতকাল" +"You" = "আপনি" +"You'll get the shared file as a new tab and edit it together." = "আপনি একটি নতুন ট্যাব হিসেবে শেয়ার করা ফাইলটি পাবেন এবং একসাথে এডিট করবেন।" +"You're spotlighting: everyone follows your view" = "আপনি স্পটলাইট করছেন: সবাই আপনার ভিউ অনুসরণ করছে" +"Your copy of the file stays open" = "ফাইলের আপনার কপিটি খোলা থাকে" +"Your copy stays open; the file stays on the server" = "আপনার কপি খোলা থাকে; ফাইলটি সার্ভারেই থাকে" +"Your name" = "আপনার নাম" +"Your name in comments" = "মন্তব্যে আপনার নাম" +"Your system asks for reduced motion: animations stay off." = "আপনার সিস্টেম কম মোশনের অনুরোধ জানায়: অ্যানিমেশন বন্ধ থাকে।" +"Z" = "Z" +"Z to A" = "Z থেকে A" +"Zoom" = "জুম" +"Zoom around the pointer" = "পয়েন্টারের চারপাশে জুম করুন" +"Zoom device to fill window" = "উইন্ডো পূর্ণ করতে ডিভাইস জুম করুন" +"Zoom in" = "জুম ইন" +"Zoom out" = "জুম আউট" +"Zoom to 100%" = "১০০%-এ জুম করুন" +"Zoom to 200%" = "২০০%-এ জুম করুন" +"Zoom to 50%" = "৫০%-এ জুম করুন" +"Zoom to fit" = "ফিট করতে জুম করুন" +"Zoom to selection" = "নির্বাচনে জুম করুন" +"Zoom tool: click to zoom in, drag an area" = "জুম টুল: জুম ইন করতে ক্লিক করুন, একটি এলাকা ড্র্যাগ করুন" +"Zoom tool: zoom out" = "জুম টুল: জুম আউট" +"and {n} more" = { one = "এবং আরও {n}টি", other = "এবং আরও {n}টি" } +"bleed marks" = "ব্লিড মার্ক" +"by {author}" = "{author} দ্বারা" +"call any command its access allows" = "এর অ্যাক্সেস অনুমতি দেয় এমন যেকোনো কমান্ড কল করুন" +"call only: {commands}" = "শুধুমাত্র কল করুন: {commands}" +"can edit the document" = "ডকুমেন্ট এডিট করতে পারেন" +"conditional prototype actions nested too deeply were skipped" = "শর্তসাপেক্ষ প্রোটোটাইপ অ্যাকশনগুলো খুব গভীরভাবে নেস্ট করার কারণে বাদ দেওয়া হয়েছে" +"conditional prototype actions with conditions that can't be evaluated were skipped" = "মূল্যায়ন করা যায় না এমন শর্তযুক্ত প্রোটোটাইপ অ্যাকশনগুলো বাদ দেওয়া হয়েছে" +"connector label background was skipped: style data is missing" = "কানেক্টর লেবেলের ব্যাকগ্রাউন্ড বাদ দেওয়া হয়েছে: স্টাইল ডেটা অনুপস্থিত" +"connector label was skipped: background bounds are invalid" = "কানেক্টর লেবেল বাদ দেওয়া হয়েছে: ব্যাকগ্রাউন্ড বাউন্ড অবৈধ" +"connector label was skipped: background transform is invalid" = "কানেক্টর লেবেল বাদ দেওয়া হয়েছে: ব্যাকগ্রাউন্ড ট্রান্সফর্ম অবৈধ" +"connector label was skipped: composed text transform is invalid" = "কানেক্টর লেবেল বাদ দেওয়া হয়েছে: কম্পোজড টেক্সট ট্রান্সফর্ম অবৈধ" +"connector label was skipped: derived background bounds are missing" = "কানেক্টর লেবেল বাদ দেওয়া হয়েছে: ডিরাইভড ব্যাকগ্রাউন্ড বাউন্ড অনুপস্থিত" +"connector label was skipped: derived text bounds are missing" = "কানেক্টর লেবেল বাদ দেওয়া হয়েছে: ডিরাইভড টেক্সট বাউন্ড অনুপস্থিত" +"connector label was skipped: text bounds are invalid" = "কানেক্টর লেবেল বাদ দেওয়া হয়েছে: টেক্সট বাউন্ড অবৈধ" +"connector label was skipped: text style data is missing" = "কানেক্টর লেবেল বাদ দেওয়া হয়েছে: টেক্সট স্টাইল ডেটা অনুপস্থিত" +"connector label was skipped: text transform is invalid" = "কানেক্টর লেবেল বাদ দেওয়া হয়েছে: টেক্সট ট্রান্সফর্ম অবৈধ" +"connector labels were skipped" = "কানেক্টর লেবেলগুলো বাদ দেওয়া হয়েছে" +"connector layers import as static frame/vector snapshots with labels when present; live endpoint bindings are not retained" = "কানেক্টর লেবেল উপস্থিত থাকলে স্ট্যাটিক ফ্রেম/ভেক্টর স্ন্যাপশট হিসেবে ইম্পোর্ট হয়; লাইভ এন্ডপয়েন্ট বাইন্ডিং রাখা হয় না" +"connector snapshot vector data is missing; connector was left empty" = "কানেক্টর স্ন্যাপশট ভেক্টর ডেটা অনুপস্থিত; কানেক্টর খালি রাখা হয়েছে" +"connectors whose ends can't be placed are empty" = "যে কানেক্টরের প্রান্তগুলো রাখা যায় না সেগুলো খালি" +"crop marks" = "ক্রপ মার্ক" +"damaged vector geometry was skipped" = "ক্ষতিগ্রস্ত ভেক্টর জিওমেট্রি বাদ দেওয়া হয়েছে" +"damaged vector networks import as outlines" = "ক্ষতিগ্রস্ত ভেক্টর নেটওয়ার্ক আউটলাইন হিসেবে ইম্পোর্ট হয়" +"development: {path}" = "ডেভেলপমেন্ট: {path}" +"document children other than pages were skipped" = "পেজ ছাড়া ডকুমেন্টের অন্যান্য চিলড্রেন বাদ দেওয়া হয়েছে" +"duplicate grid track sizing entries use equal tracks" = "ডুপ্লিকেট গ্রিড ট্র্যাক সাইজিং এন্ট্রিগুলো সমান ট্র্যাক ব্যবহার করে" +"e.g. count + 1" = "যেমন count + 1" +"e.g. count >= 3 and not done" = "যেমন count >= 3 and not done" +"elbowed and curved connectors without a saved path import as straight lines" = "সংরক্ষিত পাথ ছাড়া এলবো এবং কার্ভড কানেক্টর সরল রেখা হিসেবে ইম্পোর্ট হয়" +"entries without a node type were skipped" = "নোটের ধরন ছাড়া এন্ট্রিগুলো বাদ দেওয়া হয়েছে" +"figcraft:// links from a browser, chat or email open this app and join the session." = "ব্রাউজার, চ্যাট বা ইমেল থেকে figcraft:// লিংকগুলো এই অ্যাপ খুলে সেশনে যুক্ত হয়।" +"filled areas of a vector with their own fill use the layer's fill" = "ভেক্টরের নিজস্ব ফিলযুক্ত পূর্ণ এলাকাগুলো লেয়ারের ফিল ব্যবহার করে" +"from {server}" = "{server} থেকে" +"gamepad triggers without exactly one known controller button were skipped" = "সঠিকভাবে একটি পরিচিত কন্ট্রোলার বোতাম ছাড়া গেমপ্যাড ট্রিগার বাদ দেওয়া হয়েছে" +"grid track list exceeds 1000 entries; the tracks use the default equal sizing" = "গ্রিড ট্র্যাক তালিকা ১০০০ এন্ট্রি অতিক্রম করেছে; ট্র্যাকগুলো ডিফল্ট সমান সাইজিং ব্যবহার করে" +"grid track sizing is unsupported or malformed; affected tracks use equal sizing" = "গ্রিড ট্র্যাক সাইজিং অসমর্থিত বা ত্রুটিপূর্ণ; প্রভাবিত ট্র্যাকগুলো সমান সাইজিং ব্যবহার করে" +"grid track sizing list exceeds 1000 entries; the tracks use the default equal sizing" = "গ্রিড ট্র্যাক সাইজিং তালিকা ১০০০ এন্ট্রি অতিক্রম করেছে; ট্র্যাকগুলো ডিফল্ট সমান সাইজিং ব্যবহার করে" +"hidden" = "লুকানো" +"iOS" = "iOS" +"image filters are ambiguous because both filter containers are present; filter conversion was skipped" = "ছবির ফিল্টারগুলো দ্ব্যর্থক কারণ উভয় ফিল্টার কন্টেইনার উপস্থিত রয়েছে; ফিল্টার রূপান্তর বাদ দেওয়া হয়েছে" +"image filters use FigCraft's approximate renderer" = "ছবির ফিল্টারগুলো FigCraft-এর অনুমানমূলক রেন্ডারার ব্যবহার করে" +"instance swap properties whose default component isn't in the file were skipped" = "ইনস্ট্যান্স সোয়াপ প্রপার্টি যার ডিফল্ট কম্পোনেন্ট ফাইলে নেই তা বাদ দেওয়া হয়েছে" +"instances inside their own main component were imported as frames" = "নিজের প্রধান কম্পোনেন্টের ভেতরের ইনস্ট্যান্সগুলো ফ্রেম হিসেবে ইম্পোর্ট করা হয়েছে" +"instances of components that aren't in the file show no layers" = "ফাইলে নেই এমন কম্পোনেন্টের ইনস্ট্যান্স কোনো লেয়ার দেখায় না" +"instances of components that aren't in the file were imported as frames" = "ফাইলে নেই এমন কম্পোনেন্টের ইনস্ট্যান্স ফ্রেম হিসেবে ইম্পোর্ট করা হয়েছে" +"instances that would nest a component inside itself were imported as frames" = "যে ইনস্ট্যান্সগুলো কোনো কম্পোনেন্টকে নিজের ভেতরে নেস্ট করত সেগুলোকে ফ্রেম হিসেবে ইম্পোর্ট করা হয়েছে" +"instances without a main component were imported as empty frames" = "প্রধান কম্পোনেন্ট ছাড়া ইনস্ট্যান্সগুলো খালি ফ্রেম হিসেবে ইম্পোর্ট করা হয়েছে" +"invalid internal .fig text path data was ignored" = "অবৈধ অভ্যন্তরীণ .fig টেক্সট পাথ ডেটা উপেক্ষা করা হয়েছে" +"invalid or oversized vector networks import as outlines" = "অবৈধ বা অতিরিক্ত বড় ভেক্টর নেটওয়ার্ক আউটলাইন হিসেবে ইম্পোর্ট হয়" +"invalid or oversized vector path data was skipped" = "অবৈধ বা অতিরিক্ত বড় ভেক্টর পাথ ডেটা বাদ দেওয়া হয়েছে" +"just now" = "এইমাত্র" +"key triggers without a supported key were skipped" = "সমর্থিত কি ছাড়া কি ট্রিগার বাদ দেওয়া হয়েছে" +"key up triggers import as key presses" = "কি আপ ট্রিগার কি প্রেস হিসেবে ইম্পোর্ট হয়" +"layers without a position were placed at their parent's origin" = "অবস্থান ছাড়া লেয়ারগুলো তাদের প্যারেন্টের মূল অবস্থানে রাখা হয়েছে" +"layers without a type were skipped" = "ধরন ছাড়া লেয়ারগুলো বাদ দেওয়া হয়েছে" +"legacy image filter container is unsupported; filter conversion was skipped" = "লিগ্যাসি ইমেজ ফিল্টার কন্টেইনার অসমর্থিত; ফিল্টার রূপান্তর বাদ দেওয়া হয়েছে" +"locked" = "লক করা" +"malformed image filters container was skipped" = "ত্রুটিপূর্ণ ইমেজ ফিল্টার কন্টেইনার বাদ দেওয়া হয়েছে" +"malformed image paintFilter container was skipped" = "ত্রুটিপূর্ণ ইমেজ paintFilter কন্টেইনার বাদ দেওয়া হয়েছে" +"malformed slot content assignment was skipped" = "ত্রুটিপূর্ণ স্লট কন্টেন্ট অ্যাসাইনমেন্ট বাদ দেওয়া হয়েছে" +"no bleed" = "কোনো ব্লিড নেই" +"nobody else here yet" = "এখানে আর কেউ নেই" +"non-default slot settings are not represented and were skipped" = "অ-ডিফল্ট স্লট সেটিংস উপস্থাপিত হয়নি এবং বাদ দেওয়া হয়েছে" +"now" = "এখন" +"only in a text style or variable" = "শুধুমাত্র টেক্সট স্টাইল বা ভেরিয়েবলে" +"or" = "অথবা" +"point stroke joins were preserved; dashed or variable-width strokes render with the layer join" = "পয়েন্ট স্ট্রোক জয়েন্ট সংরক্ষিত হয়েছে; ড্যাশড বা পরিবর্তনশীল প্রস্থের স্ট্রোক লেয়ার জয়েন্ট দিয়ে রেন্ডার হয়" +"preferred slot component keys cannot be resolved to file nodes and were skipped" = "পছন্দসই স্লট কম্পোনেন্ট কি ফাইল নোডে সমাধান করা যায়নি এবং বাদ দেওয়া হয়েছে" +"prototype actions of unknown types were skipped" = "অজানা ধরনের প্রোটোটাইপ অ্যাকশন বাদ দেওয়া হয়েছে" +"prototype actions without a destination in the file were skipped" = "ফাইলে গন্তব্য নেই এমন প্রোটোটাইপ অ্যাকশন বাদ দেওয়া হয়েছে" +"prototype expressions on variables that aren't in the variables JSON were skipped" = "ভেরিয়েবল JSON-এ নেই এমন ভেরিয়েবলের উপর প্রোটোটাইপ এক্সপ্রেশন বাদ দেওয়া হয়েছে" +"prototype expressions with unsupported functions were skipped" = "অসমর্থিত ফাংশনযুক্ত প্রোটোটাইপ এক্সপ্রেশন বাদ দেওয়া হয়েছে" +"prototype interactions without supported actions were skipped" = "সমর্থিত অ্যাকশন ছাড়া প্রোটোটাইপ ইন্টারঅ্যাকশন বাদ দেওয়া হয়েছে" +"prototype navigations of unknown types were skipped" = "অজানা ধরনের প্রোটোটাইপ নেভিগেশন বাদ দেওয়া হয়েছে" +"prototype transitions of unknown types play instantly" = "অজানা ধরনের প্রোটোটাইপ ট্রানজিশন তাৎক্ষণিকভাবে চলে" +"prototype triggers of unknown types were skipped" = "অজানা ধরনের প্রোটোটাইপ ট্রিগার বাদ দেওয়া হয়েছে" +"put a widget on the canvas" = "ক্যানভাসে একটি উইজেট রাখুন" +"read and edit the document" = "ডকুমেন্ট পড়ুন এবং এডিট করুন" +"read the document" = "ডকুমেন্ট পড়ুন" +"reads the document" = "ডকুমেন্ট পড়ে" +"set-variable actions on variables that aren't in the variables JSON were skipped" = "ভেরিয়েবল JSON-এ নেই এমন ভেরিয়েবলের উপর set-variable অ্যাকশন বাদ দেওয়া হয়েছে" +"set-variable actions with values that can't be evaluated were skipped" = "মূল্যায়ন করা যায় না এমন মানযুক্ত set-variable অ্যাকশন বাদ দেওয়া হয়েছে" +"set-variable actions with values that don't fit the variable were skipped" = "ভেরিয়েবলের সাথে মেলে না এমন মানযুক্ত set-variable অ্যাকশন বাদ দেওয়া হয়েছে" +"set-variable actions without a value were skipped" = "মান ছাড়া set-variable অ্যাকশন বাদ দেওয়া হয়েছে" +"set-variable-mode actions on collections or modes that aren't in the variables JSON were skipped" = "ভেরিয়েবল JSON-এ নেই এমন সংগ্রহ বা মোডের উপর set-variable-mode অ্যাকশন বাদ দেওয়া হয়েছে" +"slot content assignment target is missing or is not slot content" = "স্লট কন্টেন্ট অ্যাসাইনমেন্ট লক্ষ্য অনুপস্থিত অথবা স্লট কন্টেন্ট নয়" +"slot content assignment uses the empty GUID sentinel and was skipped" = "স্লট কন্টেন্ট অ্যাসাইনমেন্ট খালি GUID সেন্টিমেল ব্যবহার করে এবং বাদ দেওয়া হয়েছে" +"slot layers without a matching slot property were imported as frames" = "মিল রয়েছে এমন স্লট প্রপার্টি ছাড়া স্লট লেয়ারগুলো ফ্রেম হিসেবে ইম্পোর্ট করা হয়েছে" +"some vector points refer to point settings missing from the file; they use the layer's joins and plain corners" = "কিছু ভেক্টর পয়েন্ট ফাইল থেকে অনুপস্থিত পয়েন্ট সেটিংস উল্লেখ করে; সেগুলো লেয়ারের জয়েন্ট এবং সাধারণ কোণা ব্যবহার করে" +"some vector segments refer to style settings missing from the file; they use the layer's stroke" = "কিছু ভেক্টর সেগমেন্ট ফাইল থেকে অনুপস্থিত স্টাইল সেটিংস উল্লেখ করে; সেগুলো লেয়ারের স্ট্রোক ব্যবহার করে" +"stale or invalid grid track sizing entries were ignored" = "পুরোনো বা অবৈধ গ্রিড ট্র্যাক সাইজিং এন্ট্রি উপেক্ষা করা হয়েছে" +"stars and polygons without point counts or geometry use the default point count" = "পয়েন্ট সংখ্যা বা জিওমেট্রি ছাড়া তারকা এবং বহুভুজ ডিফল্ট পয়েন্ট সংখ্যা ব্যবহার করে" +"style settings of some vector segments aren't supported; those segments use the layer's stroke" = "কিছু ভেক্টর সেগমেন্টের স্টাইল সেটিংস সমর্থিত নয়; সেই সেগমেন্টগুলো লেয়ারের স্ট্রোক ব্যবহার করে" +"text path underline or strikethrough decoration is not rendered" = "টেক্সট পাথ আন্ডারলাইন বা স্ট্রাইকথ্রু সজ্জা রেন্ডার হয় না" +"text style overrides refer to missing table entries" = "টেক্সট স্টাইল ওভাররাইড অনুপস্থিত টেবিল এন্ট্রি নির্দেশ করে" +"the file has no pages" = "ফাইলে কোনো পেজ নেই" +"the plugin" = "প্লাগইনটি" +"the variables JSON has no `variableCollections` (expected the local variables response) and was ignored" = "ভেরিয়েবল JSON-এ কোনো `variableCollections` নেই (স্থানীয় ভেরিয়েবল প্রতিক্রিয়া প্রত্যাশিত ছিল) এবং উপেক্ষা করা হয়েছে" +"this session only" = "শুধুমাত্র এই সেশন" +"unknown component property types were skipped" = "অজানা কম্পোনেন্ট প্রপার্টি ধরন বাদ দেওয়া হয়েছে" +"variable aliases to variables that aren't in the variables JSON (or of another type) were skipped" = "ভেরিয়েবল JSON-এ নেই (বা অন্য ধরনের) এমন ভেরিয়েবলের ভেরিয়েবল অ্যালিয়াস বাদ দেওয়া হয়েছে" +"variable bindings to properties the layer doesn't have were skipped" = "লেয়ারের নেই এমন প্রপার্টির ভেরিয়েবল বাইন্ডিং বাদ দেওয়া হয়েছে" +"variable bindings to variables that aren't in the variables JSON (library variables) were skipped" = "ভেরিয়েবল JSON-এ নেই (লাইব্রেরি ভেরিয়েবল) এমন ভেরিয়েবলের ভেরিয়েবল বাইন্ডিং বাদ দেওয়া হয়েছে" +"variable bindings were skipped: the file JSON has no variable definitions (pass the local variables JSON as `variables`, or embed its `meta` next to the file JSON)" = "ভেরিয়েবল বাইন্ডিং বাদ দেওয়া হয়েছে: ফাইল JSON-এ কোনো ভেরিয়েবল সংজ্ঞা নেই (স্থানীয় ভেরিয়েবল JSON কে `variables` হিসেবে পাস করুন, অথবা ফাইল JSON এর পাশে এর `meta` এমবেড করুন)" +"variable bindings whose variable type doesn't fit the property were skipped" = "প্রপার্টির সাথে মানানসই নয় এমন ভেরিয়েবল ধরনের ভেরিয়েবল বাইন্ডিং বাদ দেওয়া হয়েছে" +"variable collections without modes were skipped" = "মোড ছাড়া ভেরিয়েবল সংগ্রহ বাদ দেওয়া হয়েছে" +"variable mode lookups in prototype expressions that aren't a variable and one of its modes were skipped" = "প্রোটোটাইপ এক্সপ্রেশনের ভেরিয়েবল মোড লুকআপ যা কোনো ভেরিয়েবল এবং এর একটি মোড নয় তা বাদ দেওয়া হয়েছে" +"variable modes of collections that aren't in the file were skipped" = "ফাইলে নেই এমন সংগ্রহের ভেরিয়েবল মোড বাদ দেওয়া হয়েছে" +"variable modes of collections that aren't in the variables JSON were skipped" = "ভেরিয়েবল JSON-এ নেই এমন সংগ্রহের ভেরিয়েবল মোড বাদ দেওয়া হয়েছে" +"variable values that don't match their type (or composed colours) were skipped" = "তাদের ধরনের সাথে মেলে না এমন ভেরিয়েবল মান বাদ দেওয়া হয়েছে" +"variables of unknown types were skipped" = "অজানা ধরনের ভেরিয়েবল বাদ দেওয়া হয়েছে" +"variables whose collection isn't in the variables JSON were skipped" = "সংগ্রহ ভেরিয়েবল JSON-এ নেই এমন ভেরিয়েবল বাদ দেওয়া হয়েছে" +"variables without readable values were skipped" = "পড়ার উপযোগী মান নেই এমন ভেরিয়েবল বাদ দেওয়া হয়েছে" +"vector points with unknown stroke joins use the layer join" = "অজানা স্ট্রোক জয়েন্টযুক্ত ভেক্টর পয়েন্ট লেয়ার জয়েন্ট ব্যবহার করে" +"vectors without geometry are empty" = "জিওমেট্রি ছাড়া ভেক্টর খালি" +"vectors without geometry are empty (export the file JSON with geometry=paths)" = "জিওমেট্রি ছাড়া ভেক্টর খালি (geometry=paths সহ ফাইল JSON এক্সপোর্ট করুন)" +"very long text was cut to 1,048,576 characters" = "খুব দীর্ঘ টেক্সট কেটে ১,০৪৮,৫৭৬ অক্ষরে ছোট করা হয়েছে" +"very long vector paths were skipped" = "খুব দীর্ঘ ভেক্টর পাথ বাদ দেওয়া হয়েছে" +"video actions are not supported and were skipped" = "ভিডিও অ্যাকশন সমর্থিত নয় এবং বাদ দেওয়া হয়েছে" +"video triggers are not supported and were skipped" = "ভিডিও ট্রিগার সমর্থিত নয় এবং বাদ দেওয়া হয়েছে" +"yesterday" = "গতকাল" +"{instances} in {files}" = "{files}-এ {instances}টি ইনস্ট্যান্স" +"{language}: draft translation, waiting for a native speaker's review." = "{language}: ড্রাফট অনুবাদ, স্থানীয় ভাষাভাষীর পর্যালোচনার অপেক্ষায়।" +"{length} bleed" = "{length} ব্লিড" +"{month} {day}, {year}" = "{day} {month}, {year}" +"{name} cleared from the list" = "তালিকা থেকে {name} মুছে ফেলা হয়েছে" +"{name} is forgotten on this computer; the file stays on {server}" = "এই কম্পিউটার থেকে {name} মুছে ফেলা হয়েছে; ফাইলটি {server}-এ রয়ে গেছে" +"{name} is on the server." = "{name} সার্ভারে আছে।" +"{name} is spotlighting" = "{name} স্পটলাইট করছে" +"{name}\nClick to apply to the selection" = "{name}\nনির্বাচনে প্রয়োগ করতে ক্লিক করুন" +"{name}\nDrag onto the canvas, or click to place in the middle of the view.\nHold Alt (Option) while dropping onto an instance to swap it." = "{name}\nক্যানভাসে ড্র্যাগ করুন, অথবা ভিউর মাঝখানে রাখতে ক্লিক করুন।\nকোনো ইনস্ট্যান্সে ড্রপ করার সময় সোয়াপ করতে Alt (Option) চেপে ধরে রাখুন।" +"{name} (from {library})" = "{name} ({library} থেকে)" +"{name} (you)" = "{name} (আপনি)" +"{name} ({layers} layers)" = "{name} ({layers}টি লেয়ার)" +"{name} ({tag}): {min} – {max}, default {default}. Double-click the slider to go back to the style's value; Escape while dragging cancels." = "{name} ({tag}): {min} – {max}, ডিফল্ট {default}. স্টাইলের মানে ফিরে যেতে স্লাইডারে ডাবল-ক্লিক করুন; ড্র্যাগ করার সময় Escape টিপলে বাতিল হবে।" +"{name} from {path}" = "{path} থেকে {name}" +"{name} was removed from the server" = "সার্ভার থেকে {name} সরানো হয়েছে" +"{name} · deleted on main" = "{name} · প্রধান (main)-এ ডিলিট করা হয়েছে" +"{name} · deleted on the branch" = "{name} · ব্রাঞ্চে ডিলিট করা হয়েছে" +"{name} · not published" = "{name} · প্রকাশিত নয়" +"{name} · on a server" = "{name} · একটি সার্ভারে" +"{name} · published version {v}" = "{name} · প্রকাশিত সংস্করণ {v}" +"{name} · version {v} on the server" = "{name} · সার্ভারে সংস্করণ {v}" +"{name}, in a live session" = "{name}, লাইভ সেশনে" +"{name}, online, open only to you" = "{name}, অনলাইনে, শুধুমাত্র আপনার জন্য খোলা" +"{name}, {kind}, {w} by {h}, selected" = "{name}, {kind}, {w} × {h}, নির্বাচিত" +"{n} added" = { one = "{n}টি যোগ করা হয়েছে", other = "{n}টি যোগ করা হয়েছে" } +"{n} autosave versions" = { one = "{n}টি স্বয়ংক্রিয় সেভ সংস্করণ", other = "{n}টি স্বয়ংক্রিয় সেভ সংস্করণ" } +"{n} changed" = { one = "{n}টি পরিবর্তিত হয়েছে", other = "{n}টি পরিবর্তিত হয়েছে" } +"{n} columns" = { one = "{n}টি কলাম", other = "{n}টি কলাম" } +"{n} components" = { one = "{n}টি কম্পোনেন্ট", other = "{n}টি কম্পোনেন্ট" } +"{n} components and styles have newer versions" = { one = "{n}টি কম্পোনেন্ট বা স্টাইলের নতুন সংস্করণ রয়েছে", other = "{n}টি কম্পোনেন্ট এবং স্টাইলের নতুন সংস্করণ রয়েছে" } +"{n} conflicts to resolve" = { one = "{n}টি দ্বন্দ্ব সমাধান করতে হবে", other = "{n}টি দ্বন্দ্ব সমাধান করতে হবে" } +"{n} days ago" = { one = "{n} দিন আগে", other = "{n} দিন আগে" } +"{n} files" = { one = "{n}টি ফাইল", other = "{n}টি ফাইল" } +"{n} hours ago" = { one = "{n} ঘণ্টা আগে", other = "{n} ঘণ্টা আগে" } +"{n} instances" = { one = "{n}টি ইনস্ট্যান্স", other = "{n}টি ইনস্ট্যান্স" } +"{n} layers" = { one = "{n}টি লেয়ার", other = "{n}টি লেয়ার" } +"{n} layers as one file" = { one = "{n}টি লেয়ার একটি ফাইল হিসেবে", other = "{n}টি লেয়ার একটি ফাইল হিসেবে" } +"{n} layers selected" = { one = "{n}টি লেয়ার নির্বাচিত", other = "{n}টি লেয়ার নির্বাচিত" } +"{n} layers · {parts}" = { one = "{n}টি লেয়ার · {parts}", other = "{n}টি লেয়ার · {parts}" } +"{n} matches" = { one = "{n}টি মিল", other = "{n}টি মিল" } +"{n} minutes ago" = { one = "{n} মিনিট আগে", other = "{n} মিনিট আগে" } +"{n} months ago" = { one = "{n} মাস আগে", other = "{n} মাস আগে" } +"{n} more notes omitted from this report" = { one = "এই রিপোর্ট থেকে আরও {n}টি নোট বাদ দেওয়া হয়েছে", other = "এই রিপোর্ট থেকে আরও {n}টি নোট বাদ দেওয়া হয়েছে" } +"{n} of {total}" = "{total}-এর মধ্যে {n}" +"{n} people joined" = { one = "{n} জন যুক্ত হয়েছেন", other = "{n} জন যুক্ত হয়েছেন" } +"{n} preferred components" = { one = "{n}টি পছন্দের কম্পোনেন্ট", other = "{n}টি পছন্দের কম্পোনেন্ট" } +"{n} ready" = { one = "{n}টি প্রস্তুত", other = "{n}টি প্রস্তুত" } +"{n} removed" = { one = "{n}টি সরানো হয়েছে", other = "{n}টি সরানো হয়েছে" } +"{n} replies" = { one = "{n}টি উত্তর", other = "{n}টি উত্তর" } +"{n} rows" = { one = "{n}টি সারি", other = "{n}টি সারি" } +"{n} styles" = { one = "{n}টি স্টাইল", other = "{n}টি স্টাইল" } +"{n} text layers" = { one = "{n}টি টেক্সট লেয়ার", other = "{n}টি টেক্সট লেয়ার" } +"{n} variables" = { one = "{n}টি ভেরিয়েবল", other = "{n}টি ভেরিয়েবল" } +"{n} weeks ago" = { one = "{n} সপ্তাহ আগে", other = "{n} সপ্তাহ আগে" } +"{n} years ago" = { one = "{n} বছর আগে", other = "{n} বছর আগে" } +"{n}d" = "{n}d" +"{n}h" = "{n}h" +"{n}m" = "{n}m" +"{n}w" = "{n}w" +"{page}, nothing selected" = "{page}, কিছু নির্বাচিত নেই" +"{profile} (current)" = "{profile} (বর্তমান)" +"{property} is its value; {property ? \"a\" : \"b\"} picks a string. Other {…} stays as written." = "{property} হলো এর মান; {property ? \"a\" : \"b\"} একটি স্ট্রিং বাছে। অন্যান্য {…} যেমন লেখা আছে তেমনই থাকে।" +"{server} can't say what became of your files (it is older than this app)." = "{server} বলতে পারছে না আপনার ফাইলের কী হয়েছে (এটি এই অ্যাপের চেয়ে পুরনো)।" +"{server} · No longer on the server" = "{server} · আর সার্ভারে নেই" +"{server} · {shared} · Opened {date}" = "{server} · {shared} · খোলা হয়েছে {date}" +"{tool} hold {key}" = "{tool} চেপে ধরে রাখুন {key}" +"{value} isn't a colour" = "{value} কোনো রঙ নয়" +"{when} · edited" = "{when} · এডিট করা হয়েছে" +"← ↑ → ↓" = "← ↑ → ↓" +"← → ↑ ↓" = "← → ↑ ↓" +"←" = "←" +"→, Space or N" = "→, স্পেস বা N" +"↵" = "↵" +"⇥ / ⇧⇥" = "⇥ / ⇧⇥" +"⌘ arrows" = "⌘ অ্যারো" +"⌘ click" = "⌘ ক্লিক" +"⌘ scroll" = "⌘ স্ক্রল" +"⌥ drag" = "⌥ ড্র্যাগ" + + +[commands] +"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 98051314..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" @@ -310,6 +313,7 @@ status = "draft — needs native review" "Corner radius" = "Poloměr rohu" "Corner smoothing" = "Vyhlazení rohů" "Couldn't reach {server}: showing what this computer remembers." = "Server {server} není dostupný: zobrazuje se stav uložený v tomto počítači." +"Couldn't read {name}." = "Soubor {name} se nepodařilo přečíst." "Count" = "Počet" "Create" = "Vytvořit" "Create a variable collection first" = "Nejprve vytvořte kolekci proměnných" @@ -367,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" @@ -394,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" @@ -426,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" @@ -561,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" @@ -573,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" @@ -738,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)" @@ -796,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" @@ -897,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" @@ -929,8 +941,10 @@ status = "draft — needs native review" "PDF print settings" = "Nastavení tisku PDF" "Padding" = "Vnitřní odsazení" "Page" = "Stránka" +"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" @@ -973,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" @@ -1021,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" @@ -1120,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ů" @@ -1192,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" @@ -1258,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" @@ -1277,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" @@ -1327,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" @@ -1357,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" @@ -1373,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" @@ -1397,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" @@ -1629,6 +1657,7 @@ status = "draft — needs native review" "{name} is forgotten on this computer; the file stays on {server}" = "Zapomenuto v tomto počítači: {name}; soubor zůstává na serveru {server}" "{name} is on the server." = "{name} je na serveru." "{name} is spotlighting" = "{name} sdílí pohled" +"{name} is too large to open in the browser (over 512 MB)." = "Soubor {name} je příliš velký na otevření v prohlížeči (více než 512 MB)." "{name}\nClick to apply to the selection" = "{name}\nKliknutím použijete na výběr" "{name}\nDrag onto the canvas, or click to place in the middle of the view.\nHold Alt (Option) while dropping onto an instance to swap it." = "{name}\nPřetáhněte na plátno nebo kliknutím umístěte doprostřed zobrazení.\nPři puštění na instanci podržte Alt (Option) a vyměňte ji." "{name} (from {library})" = "{name} (z {library})" @@ -1726,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" @@ -1849,7 +1887,9 @@ status = "draft — needs native review" "file.exportLayers" = "Exportovat vrstvy" "file.exportSvg" = "Exportovat výběr jako SVG" "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é" @@ -2116,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" @@ -2131,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" @@ -2195,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 07669c1a..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" @@ -306,6 +310,7 @@ status = "draft — needs native review" "Corner smoothing" = "Eckenglättung" "Couldn't download the variables" = "Die Variablen konnten nicht heruntergeladen werden" "Couldn't reach {server}: showing what this computer remembers." = "{server} war nicht erreichbar: Es wird angezeigt, was dieser Computer gespeichert hat." +"Couldn't read {name}." = "{name} konnte nicht gelesen werden." "Couldn't save automatically: {error}" = "Automatisches Speichern fehlgeschlagen: {error}" "Count" = "Anzahl" "Create" = "Erstellen" @@ -360,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" @@ -370,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" @@ -386,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" @@ -419,6 +427,7 @@ status = "draft — needs native review" "Effect settings" = "Effekteinstellungen" "Effect styles" = "Effektstile" "Effects" = "Effekte" +"Elbow" = "Gewinkelt" "Ellipse" = "Ellipse" "Ellipses" = "Ellipsen" "Else" = "Sonst" @@ -549,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" @@ -559,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" @@ -714,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)" @@ -770,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" @@ -832,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." @@ -865,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" @@ -898,8 +913,10 @@ status = "draft — needs native review" "PDF print settings" = "PDF-Druckeinstellungen" "Padding" = "Innenabstand" "Page" = "Seite" +"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" @@ -941,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" @@ -987,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" @@ -1031,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" @@ -1086,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" @@ -1155,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" @@ -1212,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" @@ -1224,17 +1249,22 @@ status = "draft — needs native review" "Start ascending from / stop descending at" = "Aufwärts zählen ab / abwärts zählen bis" "Start from a blank canvas" = "Mit einer leeren Arbeitsfläche beginnen" "Start on path" = "Anfang auf dem Pfad" +"Start on the Home screen" = "Mit Start beginnen" "Start point" = "Startpunkt" "Start sharing" = "Teilen starten" "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" @@ -1278,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" @@ -1311,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" @@ -1327,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" @@ -1349,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" @@ -1412,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ß" @@ -1583,6 +1620,7 @@ status = "draft — needs native review" "{name} is forgotten on this computer; the file stays on {server}" = "{name} wurde auf diesem Computer vergessen; die Datei bleibt auf {server}" "{name} is on the server." = "{name} liegt auf dem Server." "{name} is spotlighting" = "{name} hat das Spotlight" +"{name} is too large to open in the browser (over 512 MB)." = "{name} ist zu groß, um im Browser geöffnet zu werden (über 512 MB)." "{name} was removed from the server" = "{name} wurde vom Server entfernt" "{name} · deleted on main" = "{name} · in der Hauptdatei gelöscht" "{name} · deleted on the branch" = "{name} · im Branch gelöscht" @@ -1673,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" @@ -1795,7 +1842,9 @@ status = "draft — needs native review" "file.exportLayers" = "Ebenen exportieren" "file.exportSvg" = "Auswahl als SVG exportieren" "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" @@ -1932,6 +1981,7 @@ status = "draft — needs native review" "pref.reduceMotion" = "Bewegung reduzieren" "pref.renameDuplicates" = "Duplizierte Ebenen umbenennen" "pref.showDimensions" = "Abmessungen an Objekten anzeigen" +"pref.startOnHome" = "Mit Start beginnen" "pref.userName" = "Ihr Name in Kommentaren" "proto.action.add" = "Prototyp-Aktion hinzufügen" "proto.action.move" = "Prototyp-Aktion neu anordnen" @@ -2058,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" @@ -2073,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" @@ -2137,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 17f6b4c5..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" @@ -172,6 +173,7 @@ name = "English" "Both directions" = "Both directions" "Bottom" = "Bottom" "Bottom left radius" = "Bottom left radius" +"Bottom padding" = "Bottom padding" "Bottom right radius" = "Bottom right radius" "Branch" = "Branch" "Branch archived" = "Branch archived" @@ -198,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…" @@ -248,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" @@ -275,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" @@ -319,6 +324,7 @@ name = "English" "Corner smoothing" = "Corner smoothing" "Couldn't download the variables" = "Couldn't download the variables" "Couldn't reach {server}: showing what this computer remembers." = "Couldn't reach {server}: showing what this computer remembers." +"Couldn't read {name}." = "Couldn't read {name}." "Couldn't save automatically: {error}" = "Couldn't save automatically: {error}" "Count" = "Count" "Create" = "Create" @@ -377,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" @@ -388,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" @@ -404,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" @@ -439,6 +448,7 @@ name = "English" "Effect settings" = "Effect settings" "Effect styles" = "Effect styles" "Effects" = "Effects" +"Elbow" = "Elbow" "Ellipse" = "Ellipse" "Ellipses" = "Ellipses" "Else" = "Else" @@ -575,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" @@ -587,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" @@ -632,6 +644,7 @@ name = "English" "Included" = "Included" "Included in layout" = "Included in layout" "Individual corners" = "Individual corners" +"Individual padding" = "Individual padding" "Individual strokes" = "Individual strokes" "Ink" = "Ink" "Inner shadow" = "Inner shadow" @@ -653,6 +666,7 @@ name = "English" "Interaction" = "Interaction" "Interactions" = "Interactions" "Interface design and prototyping. A free, open-source, clean-room design tool written from scratch in Rust, part of the ArtCraft Crafting Apps." = "Interface design and prototyping. A free, open-source, clean-room design tool written from scratch in Rust, part of the ArtCraft Crafting Apps." +"Interface size" = "Interface size" "Intersect" = "Intersect" "Intersects" = "Intersects" "Invalid color" = "Invalid color" @@ -702,6 +716,7 @@ name = "English" "Leaves" = "Leaves" "Left" = "Left" "Left & right" = "Left & right" +"Left padding" = "Left padding" "Left side" = "Left side" "Left side width" = "Left side width" "Letter case" = "Letter case" @@ -753,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)" @@ -812,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" @@ -878,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." @@ -915,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" @@ -950,8 +969,10 @@ name = "English" "PDF print settings" = "PDF print settings" "Padding" = "Padding" "Page" = "Page" +"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" @@ -996,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" @@ -1044,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" @@ -1061,6 +1084,7 @@ name = "English" "Reduce motion" = "Reduce motion" "Refraction" = "Refraction" "Regular expression" = "Regular expression" +"Relative to your system's display scaling (100% is the system's own size). Document zoom is separate." = "Relative to your system's display scaling (100% is the system's own size). Document zoom is separate." "Remove" = "Remove" "Remove all" = "Remove all" "Remove annotation" = "Remove annotation" @@ -1089,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" @@ -1099,6 +1124,7 @@ name = "English" "Rename on the server…" = "Rename on the server…" "Rename page" = "Rename page" "Rename to" = "Rename to" +"Reorder selection" = "Reorder selection" "Replace all" = "Replace all" "Replace mixed export settings" = "Replace mixed export settings" "Replace with" = "Replace with" @@ -1134,6 +1160,7 @@ name = "English" "Review and merge" = "Review and merge" "Ribbon" = "Ribbon" "Right" = "Right" +"Right padding" = "Right padding" "Right side" = "Right side" "Right side width" = "Right side width" "Rotate 90°" = "Rotate 90°" @@ -1144,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" @@ -1184,6 +1212,7 @@ name = "English" "Search results" = "Search results" "Search fonts" = "Search fonts" "Search layers" = "Search layers" +"Search pages" = "Search pages" "Search shortcuts" = "Search shortcuts" "Section" = "Section" "Section color" = "Section color" @@ -1219,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" @@ -1285,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" @@ -1298,18 +1331,23 @@ name = "English" "Start from a blank canvas" = "Start from a blank canvas" "Start from a template" = "Start from a template" "Start on path" = "Start on path" +"Start on the Home screen" = "Start on the Home screen" "Start point" = "Start point" "Start sharing" = "Start sharing" "Start sharing with the Share button, then pass on the code it gives" = "Start sharing with the Share button, then pass on the code it gives" "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" @@ -1354,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" @@ -1371,6 +1410,7 @@ name = "English" "The selection uses several text styles: click to apply one" = "The selection uses several text styles: click to apply one" "The server deletes this file if nobody opens it before {date}." = "The server deletes this file if nobody opens it before {date}." "The whole file" = "The whole file" +"The window is too small for {size}%, so the interface is drawn at {drawn}%. Make the window larger to use {size}%." = "The window is too small for {size}%, so the interface is drawn at {drawn}%. Make the window larger to use {size}%." "Theme" = "Theme" "There's no destination for that action" = "There's no destination for that action" "Thickness" = "Thickness" @@ -1381,6 +1421,7 @@ name = "English" "This condition is edited as an expression" = "This condition is edited as an expression" "This file" = "This file" "This file has unsaved changes" = "This file has unsaved changes" +"This file is too large to join here (up to {mb} MB)" = "This file is too large to join here (up to {mb} MB)" "This file uses fonts that aren't installed on this computer. Their text is drawn in a fallback font until you install them." = "This file uses fonts that aren't installed on this computer. Their text is drawn in a fallback font until you install them." "This file is on {server}, open only to you. Share it to let others in with a new code." = "This file is on {server}, open only to you. Share it to let others in with a new code." "This frame is new on the branch." = "This frame is new on the branch." @@ -1388,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" @@ -1402,8 +1446,10 @@ name = "English" "Top" = "Top" "Top & bottom" = "Top & bottom" "Top left radius" = "Top left radius" +"Top padding" = "Top padding" "Top right radius" = "Top right radius" "Transform" = "Transform" +"Triangle" = "Triangle" "Triangle arrow" = "Triangle arrow" "Trigger" = "Trigger" "True" = "True" @@ -1429,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" @@ -1450,6 +1496,7 @@ name = "English" "Use number keys for opacity" = "Use number keys for opacity" "Use the .figcraft or .fig extension." = "Use the .figcraft or .fig extension." "Use the layer's fill" = "Use the layer's fill" +"Use your browser's zoom to change the interface size." = "Use your browser's zoom to change the interface size." "Used in {files} · {instances}" = "Used in {files} · {instances}" "Username" = "Username" "Value" = "Value" @@ -1494,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" @@ -1667,6 +1716,7 @@ name = "English" "{name} is forgotten on this computer; the file stays on {server}" = "{name} is forgotten on this computer; the file stays on {server}" "{name} is on the server." = "{name} is on the server." "{name} is spotlighting" = "{name} is spotlighting" +"{name} is too large to open in the browser (over 512 MB)." = "{name} is too large to open in the browser (over 512 MB)." "{name}\nClick to apply to the selection" = "{name}\nClick to apply to the selection" "{name}\nDrag onto the canvas, or click to place in the middle of the view.\nHold Alt (Option) while dropping onto an instance to swap it." = "{name}\nDrag onto the canvas, or click to place in the middle of the view.\nHold Alt (Option) while dropping onto an instance to swap it." "{name} (from {library})" = "{name} (from {library})" @@ -1720,6 +1770,7 @@ name = "English" "{n}m" = "{n}m" "{n}w" = "{n}w" "{page}, nothing selected" = "{page}, nothing selected" +"{percent}%" = "{percent}%" "{profile} (current)" = "{profile} (current)" "{property} is its value; {property ? \"a\" : \"b\"} picks a string. Other {…} stays as written." = "{property} is its value; {property ? \"a\" : \"b\"} picks a string. Other {…} stays as written." "{server} can't say what became of your files (it is older than this app)." = "{server} can't say what became of your files (it is older than this app)." diff --git a/crates/ui-egui/locales/es.toml b/crates/ui-egui/locales/es.toml index d054e222..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" @@ -175,6 +179,7 @@ status = "draft — needs native review" "Both directions" = "Ambas direcciones" "Bottom" = "Abajo" "Bottom left radius" = "Radio inferior izquierdo" +"Bottom padding" = "Margen interno inferior" "Bottom right radius" = "Radio inferior derecho" "Branch" = "Rama" "Branch archived" = "Rama archivada" @@ -249,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" @@ -276,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" @@ -320,6 +325,7 @@ status = "draft — needs native review" "Corner radius" = "Radio de esquina" "Corner smoothing" = "Suavizado de esquinas" "Couldn't reach {server}: showing what this computer remembers." = "No se pudo contactar con {server}: se muestra lo que este ordenador recuerda." +"Couldn't read {name}." = "No se pudo leer {name}." "Couldn't save automatically: {error}" = "No se pudo guardar automáticamente: {error}" "Count" = "Número" "Create" = "Crear" @@ -378,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" @@ -389,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" @@ -405,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" @@ -440,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" @@ -576,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" @@ -588,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" @@ -633,6 +645,7 @@ status = "draft — needs native review" "Included" = "Incluidos" "Included in layout" = "Incluidos en el diseño" "Individual corners" = "Esquinas individuales" +"Individual padding" = "Margen interno individual" "Individual strokes" = "Trazos individuales" "Ink" = "Tinta" "Inner shadow" = "Sombra interior" @@ -654,6 +667,7 @@ status = "draft — needs native review" "Interaction" = "Interacción" "Interactions" = "Interacciones" "Interface design and prototyping. A free, open-source, clean-room design tool written from scratch in Rust, part of the ArtCraft Crafting Apps." = "Diseño de interfaces y prototipos. Una herramienta de diseño gratuita y de código abierto, de implementación independiente (clean-room), escrita desde cero en Rust, parte de las Crafting Apps de ArtCraft." +"Interface size" = "Tamaño de la interfaz" "Intersect" = "Intersecar" "Intersects" = "Intersecciones" "Invalid color" = "Color no válido" @@ -703,6 +717,7 @@ status = "draft — needs native review" "Leaves" = "Hojas" "Left" = "Izquierda" "Left & right" = "Izquierda y derecha" +"Left padding" = "Margen interno izquierdo" "Left side" = "Lado izquierdo" "Left side width" = "Ancho del lado izquierdo" "Letter case" = "Mayúsculas y minúsculas" @@ -754,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)" @@ -813,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" @@ -879,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í." @@ -916,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" @@ -951,8 +970,10 @@ status = "draft — needs native review" "PDF print settings" = "Ajustes de impresión PDF" "Padding" = "Margen interno" "Page" = "Página" +"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" @@ -997,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" @@ -1045,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" @@ -1062,6 +1085,7 @@ status = "draft — needs native review" "Reduce motion" = "Reducir movimiento" "Refraction" = "Refracción" "Regular expression" = "Expresión regular" +"Relative to your system's display scaling (100% is the system's own size). Document zoom is separate." = "Relativo al escalado de pantalla del sistema (100% es el tamaño del propio sistema). El zoom del documento es independiente." "Remove" = "Quitar" "Remove all" = "Quitar todo" "Remove annotation" = "Quitar anotación" @@ -1090,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" @@ -1100,6 +1125,7 @@ status = "draft — needs native review" "Rename on the server…" = "Cambiar el nombre en el servidor…" "Rename page" = "Cambiar nombre de página" "Rename to" = "Nuevo nombre" +"Reorder selection" = "Reordenar selección" "Replace all" = "Reemplazar todo" "Replace mixed export settings" = "Reemplazar ajustes de exportación mixtos" "Replace with" = "Reemplazar con" @@ -1135,6 +1161,7 @@ status = "draft — needs native review" "Review and merge" = "Revisar y fusionar" "Ribbon" = "Cinta" "Right" = "Derecha" +"Right padding" = "Margen interno derecho" "Right side" = "Lado derecho" "Right side width" = "Ancho del lado derecho" "Rotate 90°" = "Girar 90°" @@ -1145,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" @@ -1185,6 +1213,7 @@ status = "draft — needs native review" "Search results" = "Resultados de búsqueda" "Search fonts" = "Buscar fuentes" "Search layers" = "Buscar capas" +"Search pages" = "Buscar páginas" "Search shortcuts" = "Buscar atajos" "Section" = "Sección" "Section color" = "Color de sección" @@ -1220,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" @@ -1286,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" @@ -1299,18 +1332,23 @@ status = "draft — needs native review" "Start from a blank canvas" = "Empieza con un lienzo en blanco" "Start from a template" = "Empieza con una plantilla" "Start on path" = "Inicio en el trazado" +"Start on the Home screen" = "Empezar en Inicio" "Start point" = "Punto inicial" "Start sharing" = "Empezar a compartir" "Start sharing with the Share button, then pass on the code it gives" = "Empieza a compartir con el botón Compartir y pasa el código que te da" "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" @@ -1355,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" @@ -1372,6 +1411,7 @@ status = "draft — needs native review" "The selection uses several text styles: click to apply one" = "La selección usa varios estilos de texto: haz clic para aplicar uno" "The server deletes this file if nobody opens it before {date}." = "El servidor elimina este archivo si nadie lo abre antes del {date}." "The whole file" = "El archivo completo" +"The window is too small for {size}%, so the interface is drawn at {drawn}%. Make the window larger to use {size}%." = "La ventana es demasiado pequeña para {size}%, así que la interfaz se dibuja al {drawn}%. Agranda la ventana para usar {size}%." "Theme" = "Tema" "There's no destination for that action" = "No hay destino para esa acción" "Thickness" = "Grosor" @@ -1382,6 +1422,7 @@ status = "draft — needs native review" "This condition is edited as an expression" = "Esta condición se edita como expresión" "This file" = "Este archivo" "This file has unsaved changes" = "Este archivo tiene cambios sin guardar" +"This file is too large to join here (up to {mb} MB)" = "Este archivo es demasiado grande para unirte aquí (hasta {mb} MB)" "This file uses fonts that aren't installed on this computer. Their text is drawn in a fallback font until you install them." = "Este archivo usa fuentes que no están instaladas en este equipo. Su texto se dibuja con una fuente de reserva hasta que las instales." "This file is on {server}, open only to you. Share it to let others in with a new code." = "Este archivo está en {server}, abierto solo para ti. Compártelo para dejar entrar a otros con un código nuevo." "This frame is new on the branch." = "Este marco es nuevo en la rama." @@ -1389,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" @@ -1403,8 +1447,10 @@ status = "draft — needs native review" "Top" = "Arriba" "Top & bottom" = "Arriba y abajo" "Top left radius" = "Radio superior izquierdo" +"Top padding" = "Margen interno superior" "Top right radius" = "Radio superior derecho" "Transform" = "Transformar" +"Triangle" = "Triángulo" "Triangle arrow" = "Flecha triangular" "Trigger" = "Desencadenante" "True" = "Verdadero" @@ -1430,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" @@ -1451,6 +1497,7 @@ status = "draft — needs native review" "Use number keys for opacity" = "Usar teclas numéricas para la opacidad" "Use the .figcraft or .fig extension." = "Usa la extensión .figcraft o .fig." "Use the layer's fill" = "Usar el relleno de la capa" +"Use your browser's zoom to change the interface size." = "Usa el zoom del navegador para cambiar el tamaño de la interfaz." "Used in {files} · {instances}" = "Usado en {files} · {instances}" "Username" = "Usuario" "Value" = "Valor" @@ -1495,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" @@ -1668,6 +1717,7 @@ status = "draft — needs native review" "{name} is forgotten on this computer; the file stays on {server}" = "{name} se olvidó en este ordenador; el archivo se queda en {server}" "{name} is on the server." = "{name} está en el servidor." "{name} is spotlighting" = "{name} está en el foco" +"{name} is too large to open in the browser (over 512 MB)." = "{name} es demasiado grande para abrirlo en el navegador (más de 512 MB)." "{name}\nClick to apply to the selection" = "{name}\nHaz clic para aplicarlo a la selección" "{name}\nDrag onto the canvas, or click to place in the middle of the view.\nHold Alt (Option) while dropping onto an instance to swap it." = "{name}\nArrastra al lienzo o haz clic para colocarlo en el centro de la vista.\nMantén Alt (Opción) al soltar sobre una instancia para intercambiarla." "{name} (from {library})" = "{name} (de {library})" @@ -1721,6 +1771,7 @@ status = "draft — needs native review" "{n}m" = "{n} min" "{n}w" = "{n} sem" "{page}, nothing selected" = "{page}, nada seleccionado" +"{percent}%" = "{percent}%" "{profile} (current)" = "{profile} (actual)" "{property} is its value; {property ? \"a\" : \"b\"} picks a string. Other {…} stays as written." = "{property} es su valor; {property ? \"a\" : \"b\"} elige un texto. Cualquier otro {…} se queda tal cual." "{server} can't say what became of your files (it is older than this app)." = "{server} no puede decir qué fue de tus archivos (es anterior a esta aplicación)." @@ -1763,9 +1814,19 @@ status = "draft — needs native review" "arrange.front" = "Traer al frente" "arrange.packH" = "Agrupar en horizontal" "arrange.packV" = "Agrupar en vertical" +"arrange.reorder" = "Reordenar selección" "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" @@ -1890,7 +1951,9 @@ status = "draft — needs native review" "file.exportLayers" = "Exportar capas" "file.exportSvg" = "Exportar selección como SVG" "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" @@ -2032,6 +2095,8 @@ status = "draft — needs native review" "pref.reduceMotion" = "Reducir movimiento" "pref.renameDuplicates" = "Renombrar capas duplicadas" "pref.showDimensions" = "Mostrar dimensiones en los objetos" +"pref.startOnHome" = "Empezar en Inicio" +"pref.uiScale" = "Tamaño de la interfaz" "pref.userName" = "Tu nombre en los comentarios" "proto.action.add" = "Añadir acción de prototipo" "proto.action.move" = "Reordenar acción de prototipo" @@ -2137,6 +2202,7 @@ status = "draft — needs native review" "text.listIndent" = "Aumentar sangría de la lista" "text.listOutdent" = "Reducir sangría de la lista" "text.listStyle" = "Estilo de lista" +"text.loadFont" = "Cargar archivo de fuente" "text.maxLines" = "Máximo de líneas" "text.missingFonts" = "Fuentes que faltan" "text.numberList" = "Lista numerada" @@ -2145,7 +2211,9 @@ status = "draft — needs native review" "text.paragraphSpacing" = "Espacio entre párrafos" "text.pathOptions" = "Opciones de texto en trazado" "text.preview" = "Previsualizar cambio de texto" +"text.refit" = "Ajustar texto a las fuentes" "text.removeFromPath" = "Quitar texto del trazado" +"text.rescanFonts" = "Volver a buscar fuentes instaladas" "text.set" = "Establecer texto" "text.setFeatures" = "Funciones OpenType" "text.setVariations" = "Ejes de fuente variable" @@ -2160,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" @@ -2175,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" @@ -2239,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 new file mode 100644 index 00000000..f18ffeaf --- /dev/null +++ b/crates/ui-egui/locales/fi.toml @@ -0,0 +1,2396 @@ +# Finnish interface catalog. Original translations; MIT OR Apache-2.0. +# AI-assisted draft; not reviewed by a human Finnish/native speaker. +# Sentence case, imperative commands. Integer plurals: 1 taso, 0/2/21 tasoa. +# Product names, shortcut notation, numeric glyphs and code examples retain their identity. +# Command ids and context suffixes are immutable keys; translated text is display-only. +# Completeness measured against the English UI and live command/menu source sets; see tests. + +[meta] +name = "Suomi" +status = "draft — needs native review" + +[ui] +"% of all commits" = "% kaikista muutoksista" +"(no longer published)" = "(ei enää julkaistu)" +"({n} warnings: some entries were skipped)" = { one = "({n} varoitus: joitakin kohteita ohitettiin)", other = "({n} varoitusta: joitakin kohteita ohitettiin)" } +"+ Create variable" = "+ Luo muuttuja" +".fig (experimental)" = ".fig (kokeellinen)" +"1…9, 0" = "1…9, 0" +"@{login}: {prs} PRs, {commits} commits, +{added} / −{deleted} lines (Δ {delta}), +{bin_added} / −{bin_deleted} binary assets, {first} – {last}" = "@{login}: PR:t {prs}, muutokset {commits}, rivit +{added} / −{deleted} (Δ {delta}), binääriresurssit +{bin_added} / −{bin_deleted}, {first} – {last}" +"A .figcraft file with this name already exists. Choose its full name to confirm replacement." = "Tämänniminen .figcraft-tiedosto on jo olemassa. Vahvista korvaaminen valitsemalla sen koko nimi." +"A collection of this file with these modes and variables: override their values per mode and add variables of its own" = "Tämän tiedoston kokoelma, joka sisältää nämä tilat ja muuttujat. Voit ohittaa niiden arvot tilakohtaisesti ja lisätä omia muuttujia." +"A layer on its own aligns to its frame: select more layers, or one in a frame" = "Yksittäinen taso tasataan kehykseensä. Valitse useampia tasoja tai yksi kehyksen sisällä oleva taso." +"A responsive product page: hero, features, figures, a quote and a footer" = "Mukautuva tuotesivu: pääosio, ominaisuudet, tunnusluvut, lainaus ja alatunniste" +"A spring plays until it settles: its mass, stiffness and damping set how long" = "Jousi liikkuu, kunnes se asettuu. Sen massa, jäykkyys ja vaimennus määräävät keston." +"A title, an agenda, a big figure, a chart and a closing slide" = "Otsikko, esityslista, suuri tunnusluku, kaavio ja päätösdia" +"A to Z" = "A:sta Z:aan" +"About" = "Tietoja" +"About FigCraft" = "Tietoja FigCraftista" +"About this server" = "Tietoja tästä palvelimesta" +"Action" = "Toiminto" +"Actions" = "Toiminnot" +"Actions for {name}" = "Kohteen {name} toiminnot" +"Actions…" = "Toiminnot…" +"Active" = "Aktiivinen" +"Actual size (100%)" = "Todellinen koko (100 %)" +"Add a category" = "Lisää luokka" +"Add a comment" = "Lisää kommentti" +"Add a description" = "Lisää kuvaus" +"Add a frame (F) or a shape (R) to get started" = "Aloita lisäämällä kehys (F) tai muoto (R)" +"Add a layer or component to tile first" = "Lisää ensin laatoitettava taso tai komponentti" +"Add a note for developers" = "Lisää huomautus kehittäjille" +"Add action" = "Lisää toiminto" +"Add actions for when no condition holds" = "Lisää toimintoja tilanteeseen, jossa mikään ehto ei täyty" +"Add an annotation for developers" = "Lisää huomautus kehittäjille" +"Add another condition" = "Lisää toinen ehto" +"Add auto layout" = "Lisää automaattinen asettelu" +"Add background behind overlay" = "Lisää tausta peittokuvan taakse" +"Add content to this slot" = "Lisää sisältöä tähän sisältöpaikkaan" +"Add dev resource" = "Lisää kehittäjäresurssi" +"Add effect" = "Lisää tehoste" +"Add export setting" = "Lisää vientiasetus" +"Add fill" = "Lisää täyttö" +"Add interaction" = "Lisää vuorovaikutus" +"Add layout grid" = "Lisää asetteluruudukko" +"Add library" = "Lisää kirjasto" +"Add library file…" = "Lisää kirjastotiedosto…" +"Add max height" = "Lisää enimmäiskorkeus" +"Add max width" = "Lisää enimmäisleveys" +"Add min height" = "Lisää vähimmäiskorkeus" +"Add min width" = "Lisää vähimmäisleveys" +"Add mode" = "Lisää tila" +"Add new page" = "Lisää uusi sivu" +"Add or update collections from a variables JSON" = "Lisää tai päivitä kokoelmia muuttujien JSON-tiedostosta" +"Add properties to make instances configurable" = "Lisää ominaisuuksia, jotta ilmentymiä voi mukauttaa" +"Add reaction" = "Lisää reaktio" +"Add starting point" = "Lisää aloituspiste" +"Add stroke" = "Lisää reunaviiva" +"Add variant" = "Lisää variantti" +"Add variant with a new value" = "Lisää variantti uudella arvolla" +"Added" = "Lisätty" +"Added the library \"{name}\"" = "Kirjasto ”{name}” lisätty" +"Adding to {name}" = "Lisätään kohteeseen {name}" +"Adjust" = "Säädä" +"Adjustment presets" = "Säädön esiasetukset" +"Advanced" = "Lisäasetukset" +"Advanced stroke" = "Reunaviivan lisäasetukset" +"After delay" = "Viiveen jälkeen" +"After delay {ms}ms" = "Viiveen {ms}ms jälkeen" +"Align" = "Tasaa" +"Align bottom" = "Tasaa alas" +"Align center" = "Keskitä" +"Align horizontal centers" = "Keskitä vaakasuunnassa" +"Align left" = "Tasaa vasemmalle" +"Align middle" = "Tasaa keskelle" +"Align right" = "Tasaa oikealle" +"Align text baselines" = "Tasaa tekstin peruslinjat" +"Align top" = "Tasaa ylös" +"Align vertical centers" = "Keskitä pystysuunnassa" +"Alignment" = "Tasaus" +"All" = "Kaikki" +"All categories" = "Kaikki luokat" +"All comments" = "Kaikki kommentit" +"All components" = "Kaikki komponentit" +"All fonts" = "Kaikki fontit" +"All supported properties" = "Kaikki tuetut ominaisuudet" +"All the fonts this file uses are installed" = "Kaikki tämän tiedoston käyttämät fontit on asennettu" +"All variables" = "Kaikki muuttujat" +"Alpha" = "Alfa" +"Alphabetical" = "Aakkosjärjestys" +"Alt+drag" = "Alt+drag" +"An analytics screen on a grid: figures, charts drawn from vectors and a table" = "Analytiikkanäkymä ruudukossa: tunnuslukuja, vektoreista piirrettyjä kaavioita ja taulukko" +"An invite link that opens FigCraft and joins" = "Kutsulinkki, joka avaa FigCraftin ja liittyy istuntoon" +"Anchor" = "Ankkuri" +"Android" = "Android" +"Angular" = "Kulmaliukuväri" +"Animation" = "Animaatio" +"Annotations" = "Huomautukset" +"Annotations shown on the canvas" = "Piirtoalueella näytettävät huomautukset" +"Another server, or one that needs its key. The usual server is set in Preferences." = "Toinen palvelin tai avainta tarvitseva palvelin. Tavallinen palvelin asetetaan asetuksissa." +"Any status" = "Kaikki tilat" +"Anyone on your network with this code can edit the file, while this app stays open." = "Kuka tahansa lähiverkossasi voi muokata tiedostoa tällä koodilla niin kauan kuin sovellus on auki." +"Anyone with the edit link can change the file; the view link only lets people look. The links carry their secrets: share them like passwords. `figcraft-server links` makes more and revokes them." = "Kuka tahansa muokkauslinkin saanut voi muuttaa tiedostoa. Katselulinkillä voi vain katsella. Linkit sisältävät salaiset tunnisteet: jaa niitä kuin salasanoja. `figcraft-server links` luo lisää linkkejä ja peruu niitä." +"Anyone with this code can edit the file." = "Kuka tahansa tämän koodin saanut voi muokata tiedostoa." +"Anyone with this code can look at the file, not change it." = "Kuka tahansa tämän koodin saanut voi katsella tiedostoa, mutta ei muuttaa sitä." +"Appearance" = "Ulkoasu" +"Apply a component property" = "Käytä komponentin ominaisuutta" +"Apply variable" = "Käytä muuttujaa" +"Apply variable to text" = "Käytä muuttujaa tekstiin" +"Apply variable to visibility" = "Käytä muuttujaa näkyvyyteen" +"Apps" = "Sovellukset" +"Apr" = "huhtik." +"Arc" = "Kaari" +"Arc ratio" = "Kaaren suhde" +"Arc start" = "Kaaren alku" +"Arc sweep" = "Kaaren pyyhkäisy" +"Archive" = "Arkistoi" +"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" +"Aug" = "elok." +"Auto" = "Automaattinen" +"Auto (space around)" = "Automaattinen (tila ympärillä)" +"Auto (space between)" = "Automaattinen (tila välissä)" +"Auto (space evenly)" = "Automaattinen (tasavälein)" +"Auto ({mode})" = "Automaattinen ({mode})" +"Auto columns" = "Automaattiset sarakkeet" +"Auto gap" = "Automaattinen väli" +"Auto height" = "Automaattinen korkeus" +"Auto layout" = "Automaattinen asettelu" +"Auto rows" = "Automaattiset rivit" +"Auto width" = "Automaattinen leveys" +"Back" = "Takaisin" +"Back to Ready for dev" = "Palauta kehitysvalmiiksi" +"Back to Ready for dev: the next change compares with how it is now" = "Palauta kehitysvalmiiksi: seuraavaa muutosta verrataan nykyiseen tilaan" +"Back to assets" = "Takaisin resursseihin" +"Back to current" = "Takaisin nykyiseen" +"Back to styles" = "Takaisin tyyleihin" +"Back to the FigCraft server" = "Takaisin FigCraft-palvelimelle" +"Back to the canvas from a focused region" = "Palaa kohdistetulta alueelta piirtoalueelle" +"Back to the current version" = "Takaisin nykyiseen versioon" +"Back to the font's value" = "Palauta fontin arvo" +"Back to the values the font style implies" = "Palauta fonttityylin mukaiset arvot" +"Background" = "Tausta" +"Background blur" = "Taustan sumennus" +"Basics" = "Perusteet" +"Before" = "Ennen" +"Bend" = "Taivutus" +"Bevel" = "Viiste" +"Binary assets added" = "Lisätyt binääriresurssit" +"Binary assets removed" = "Poistetut binääriresurssit" +"Black & white" = "Mustavalkoinen" +"Bleed" = "Leikkuuvara" +"Bleed marks" = "Leikkuuvaramerkit" +"Bleed marks need a bleed" = "Leikkuuvaramerkit edellyttävät leikkuuvaraa" +"Blend" = "Sekoitus" +"Blend mode" = "Sekoitustila" +"Blue" = "Sininen" +"Blur" = "Sumennus" +"Boolean" = "Totuusarvo" +"Boolean groups" = "Boolen ryhmät" +"Both directions" = "Molemmat suunnat" +"Bottom" = "Alhaalta" +"Bottom left radius" = "Vasemman alakulman säde" +"Bottom right radius" = "Oikean alakulman säde" +"Bound to {name}. Click to detach the variable." = "Sidottu muuttujaan {name}. Irrota muuttuja napsauttamalla." +"Branch" = "Haara" +"Branch archived" = "Haara arkistoitu" +"Branch merged" = "Haara yhdistetty" +"Branch name" = "Haaran nimi" +"Branch of {file}" = "Tiedoston {file} haara" +"Branch of {file} · merged or archived" = "Tiedoston {file} haara · yhdistetty tai arkistoitu" +"Branch updated from main" = "Haara päivitetty päähaarasta" +"Branch {name}" = "Haara {name}" +"Branches" = "Haarat" +"Bring forward" = "Tuo eteenpäin" +"Bring the main file's changes into the branch" = "Tuo päätiedoston muutokset haaraan" +"Bring to front" = "Tuo etummaiseksi" +"Bristle" = "Harjas" +"Browse" = "Selaa" +"Brush" = "Sivellin" +"Brush rotation jitter" = "Siveltimen kierron vaihtelu" +"Brush scatter" = "Siveltimen hajonta" +"Brush size jitter" = "Siveltimen koon vaihtelu" +"Brush spacing" = "Siveltimen välistys" +"Bulleted list" = "Luettelomerkitty luettelo" +"C or Shift+C" = "C or Shift+C" +"C or ⇧C" = "C or ⇧C" +"CMYK colour" = "CMYK-väri" +"CSS custom properties…" = "CSS:n mukautetut ominaisuudet…" +"Can edit" = "Voi muokata" +"Can view" = "Voi katsella" +"Can't read this library: {e}" = "Kirjastoa ei voi lukea: {e}" +"Cancel" = "Peruuta" +"Canvas" = "Piirtoalue" +"Canvas stacking" = "Piirtoalueen pinoamisjärjestys" +"Cap height to baseline" = "Versaalikorkeudesta peruslinjaan" +"Case" = "Kirjainkoko" +"Case-sensitive forms" = "Kirjainkokoon mukautuvat muodot" +"Categories" = "Luokat" +"Center" = "Keskelle" +"Change alias" = "Vaihda alias" +"Change resolved" = "Muutos ratkaistu" +"Change style" = "Vaihda tyyli" +"Change to" = "Vaihda kohteeksi" +"Change to needs a layer inside a variant" = "Vaihto edellyttää variantin sisällä olevaa tasoa" +"Changed" = "Muutettu" +"Changed on both sides: keep one" = "Muutettu molemmilla puolilla: säilytä toinen" +"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" +"Choose image" = "Valitse kuva" +"Choose mode" = "Valitse tila" +"Choose variable" = "Valitse muuttuja" +"Circle" = "Ympyrä" +"Clear from this list" = "Poista tästä luettelosta" +"Clear recent" = "Tyhjennä viimeisimmät" +"Click + to replace mixed content" = "Korvaa vaihteleva sisältö napsauttamalla +" +"Click anywhere on the canvas to leave one." = "Lisää huomautus napsauttamalla mitä tahansa piirtoalueen kohtaa." +"Click or drag to merge · {alt} removes" = "Yhdistä napsauttamalla tai vetämällä · {alt} poistaa" +"Click or drag to remove · {alt} merges" = "Poista napsauttamalla tai vetämällä · {alt} yhdistää" +"Click to record a key" = "Tallenna näppäin napsauttamalla" +"Clip content" = "Leikkaa sisältö rajoihin" +"Clip to shape" = "Leikkaa muotoon" +"Close" = "Sulje" +"Close overlay" = "Sulje peittokuva" +"Close preview" = "Sulje esikatselu" +"Close tab" = "Sulje välilehti" +"Close version history" = "Sulje versiohistoria" +"Close when clicking outside" = "Sulje napsautettaessa ulkopuolelle" +"Code" = "Koodi" +"Code or invite link" = "Koodi tai kutsulinkki" +"Code syntax" = "Koodin syntaksi" +"Collapse" = "Tiivistä muunnokset" +"Collapse panel" = "Supista paneeli" +"Collection" = "Kokoelma" +"Collections" = "Kokoelmat" +"Color" = "Väri" +"Color burn" = "Värivarjostus" +"Color dodge" = "Värilisävalotus" +"Color format" = "Värimuoto" +"Color profile" = "Väriprofiili" +"Color profile: {name}" = "Väriprofiili: {name}" +"Color styles" = "Värityylit" +"Color values in this file are in this color space." = "Tämän tiedoston väriarvot ovat tässä väriavaruudessa." +"Color variables" = "Värimuuttujat" +"Colour" = "Väri" +"Colour and text styles, Light and Dark variables, buttons, inputs and cards" = "Väri- ja tekstityylejä, vaalean ja tumman tilan muuttujia, painikkeita, syötekenttiä ja kortteja" +"Column" = "Sarake" +"Column gap" = "Sarakkeiden väli" +"Column sizes" = "Sarakkeiden koot" +"Column span" = "Sarakeulottuvuus" +"Columns" = "Sarakkeet" +"Columns {count}" = "Sarakkeita: {count}" +"Comment" = "Kommentti" +"Comments" = "Kommentit" +"Commits" = "Muutokset" +"Company" = "Yritys" +"Compare changes" = "Vertaa muutoksia" +"Compatibility notes" = "Yhteensopivuushuomautukset" +"Completed" = "Valmis" +"Component" = "Komponentti" +"Component name" = "Komponentin nimi" +"Component property" = "Komponentin ominaisuus" +"Component set" = "Komponenttijoukko" +"Component sets" = "Komponenttijoukot" +"Components" = "Komponentit" +"Conditional" = "Ehdollinen" +"Confetti" = "Konfetti" +"Conflicts" = "Ristiriidat" +"Connect to code" = "Yhdistä koodiin" +"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ö" +"Contents hidden" = "Sisältö kätketty" +"Contents only" = "Vain sisältö" +"Contextual alternates" = "Kontekstuaaliset vaihtoehdot" +"Contrast" = "Kontrasti" +"Contributors" = "Tekijät" +"Contributors: {people} · Commits: {commits}" = "Tekijät: {people} · Muutokset: {commits}" +"Controller" = "Ohjain" +"Convert to frame" = "Muunna kehykseksi" +"Convert to section" = "Muunna osioksi" +"Cool" = "Viileä" +"Copied as CSS" = "Kopioitu CSS-muodossa" +"Copied as PNG" = "Kopioitu PNG-muodossa" +"Copied as SVG" = "Kopioitu SVG-muodossa" +"Copied as text" = "Kopioitu tekstinä" +"Copied {value}" = "Kopioitu: {value}" +"Copy" = "Kopioi" +"Copy as" = "Kopioi muodossa" +"Copy as CSS" = "Kopioi CSS-muodossa" +"Copy as PNG" = "Kopioi PNG-muodossa" +"Copy as SVG" = "Kopioi SVG-muodossa" +"Copy code" = "Kopioi koodi" +"Copy link" = "Kopioi linkki" +"Copy the {label}" = "Kopioi: {label}" +"Copy {value}" = "Kopioi: {value}" +"Corner radius" = "Kulman säde" +"Corner smoothing" = "Kulman pehmennys" +"Couldn't download the variables" = "Muuttujia ei voitu ladata" +"Couldn't reach {server}: showing what this computer remembers." = "Palvelimeen {server} ei saatu yhteyttä. Näytetään tämän tietokoneen muistamat tiedot." +"Couldn't read {name}." = "Tiedostoa {name} ei voitu lukea." +"Couldn't save automatically: {error}" = "Automaattinen tallennus epäonnistui: {error}" +"Count" = "Määrä" +"Create" = "Luo" +"Create a variable collection first" = "Luo ensin muuttujakokoelma" +"Create a variable first" = "Luo ensin muuttuja" +"Create alias" = "Luo alias" +"Create branch" = "Luo haara" +"Create collection" = "Luo kokoelma" +"Create component" = "Luo komponentti" +"Create component property" = "Luo komponentin ominaisuus" +"Create components, styles or variables to publish them as a library." = "Luo komponentteja, tyylejä tai muuttujia, jotta voit julkaista ne kirjastona." +"Create property…" = "Luo ominaisuus…" +"Create style" = "Luo tyyli" +"Create style from the selected text" = "Luo tyyli valitusta tekstistä" +"Created" = "Luotu" +"Crop" = "Rajaa" +"Crop marks" = "Leikkausmerkit" +"Ctrl+arrows" = "Ctrl+arrows" +"Ctrl+click" = "Ctrl+click" +"Ctrl+scroll" = "Ctrl+scroll" +"Current name" = "Nykyinen nimi" +"Current version" = "Nykyinen versio" +"Cursor chat" = "Kohdistinkeskustelu" +"Custom" = "Mukautettu" +"Custom size" = "Mukautettu koko" +"Cut" = "Leikkaa" +"Dark" = "Tumma" +"Darken" = "Tummenna" +"Dash" = "Katkoviiva" +"Dashboard" = "Koontinäkymä" +"Date created" = "Luontipäivä" +"Dec" = "jouluk." +"Decoration" = "Koristelu" +"Decoration style" = "Koristelun tyyli" +"Deep select" = "Valitse sisäkkäinen kohde" +"Default" = "Oletus" +"Default content" = "Oletussisältö" +"Default value (the top-left variant)" = "Oletusarvo (vasemman yläkulman variantti)" +"Delay" = "Viive" +"Delete" = "Poista" +"Delete category (its annotations stay)" = "Poista luokka (sen huomautukset säilyvät)" +"Delete collection" = "Poista kokoelma" +"Delete group" = "Poista ryhmä" +"Delete mode" = "Poista tila" +"Delete page" = "Poista sivu" +"Delete preset" = "Poista esiasetus" +"Delete property" = "Poista ominaisuus" +"Delete the style (layers keep their values)" = "Poista tyyli (tasojen arvot säilyvät)" +"Delete thread" = "Poista keskustelu" +"Delete variable" = "Poista muuttuja" +"Delete “{name}”? This can't be undone." = "Poistetaanko ”{name}”? Tätä ei voi kumota." +"Density" = "Tiheys" +"Depth" = "Syvyys" +"Describe how and when to use this component" = "Kuvaile, miten ja milloin tätä komponenttia käytetään" +"Describe what changed" = "Kuvaile muutokset" +"Description" = "Kuvaus" +"Description (optional)" = "Kuvaus (valinnainen)" +"Design" = "Suunnittelu" +"Design panel" = "Suunnittelupaneeli" +"Design system starter" = "Muotoilujärjestelmän aloituspohja" +"Desktop" = "Työpöytä" +"Destination" = "Kohde" +"Detach alias" = "Irrota alias" +"Detach instance" = "Irrota ilmentymä" +"Detach property" = "Irrota ominaisuus" +"Detach style" = "Irrota tyyli" +"Detach styles" = "Irrota tyylit" +"Detach variable" = "Irrota muuttuja" +"Details" = "Tiedot" +"Dev Mode" = "Kehittäjätila" +"Dev resources" = "Kehittäjäresurssit" +"Development" = "Kehitys" +"Device" = "Laite" +"Device at 100%" = "Laite koossa 100 %" +"Diamond" = "Vinoneliö" +"Difference" = "Erotus" +"Dimensions" = "Mitat" +"Direction" = "Suunta" +"Disconnect from code" = "Katkaise yhteys koodiin" +"Dispersion" = "Hajonta" +"Display name" = "Näyttönimi" +"Dissolve" = "Häivytys" +"Distribute and tidy" = "Jaa ja järjestä" +"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" +"Dots" = "Pisteet" +"Dotted" = "Pisteviiva" +"Down" = "Alas" +"Drafts" = "Luonnokset" +"Drag the stroke to add a width point" = "Lisää leveyspiste vetämällä reunaviivaa" +"Draw" = "Piirrä" +"Draw a frame" = "Piirrä kehys" +"Drawing tools" = "Piirtotyökalut" +"Drop a .figcraft, .fig, .json, .svg or image file." = "Pudota .figcraft-, .fig-, .json-, .svg- tai kuvatiedosto." +"Drop shadow" = "Heittovarjo" +"Drop to import" = "Tuo pudottamalla" +"Drop to open" = "Avaa pudottamalla" +"Dual-licensed MIT OR Apache-2.0. Made by the ArtCraft team and community." = "Kaksoislisensoitu: MIT OR Apache-2.0. ArtCraft-tiimin ja yhteisön tekemä." +"Duo" = "Kaksivärinen" +"Duplicate" = "Monista" +"Duplicate by dragging" = "Monista vetämällä" +"Duplicate mode" = "Monista tila" +"Duplicate page" = "Monista sivu" +"Edit" = "Muokkaa" +"Edit as an expression" = "Muokkaa lausekkeena" +"Edit colour" = "Muokkaa väriä" +"Edit comment" = "Muokkaa kommenttia" +"Edit dev resource" = "Muokkaa kehittäjäresurssia" +"Edit link" = "Muokkaa linkkiä" +"Edit link copied" = "Muokkauslinkki kopioitu" +"Edit style" = "Muokkaa tyyliä" +"Edit variable" = "Muokkaa muuttujaa" +"Edit with the builder" = "Muokkaa rakentimella" +"Edited {when}" = "Muokattu {when}" +"Effect settings" = "Tehosteen asetukset" +"Effect styles" = "Tehostetyylit" +"Effects" = "Tehosteet" +"Elbow" = "Kulmikas" +"Ellipse" = "Ellipsi" +"Ellipses" = "Ellipsit" +"Else" = "Muuten" +"Else if" = "Muuten jos" +"Embedded profile" = "Upotettu profiili" +"Empty" = "Tyhjä" +"End" = "Loppu" +"End point" = "Loppupiste" +"Ends the session; everyone keeps their copy" = "Lopettaa istunnon. Kaikki säilyttävät oman kopionsa." +"Enter" = "Enter" +"Enter the address of a figcraft-server and press Browse to see the plugins it offers." = "Anna figcraft-server-palvelimen osoite ja näytä sen tarjoamat liitännäiset painamalla Selaa." +"Enter to save · Esc to cancel" = "Tallenna Enterillä · peruuta Escillä" +"Eraser" = "Pyyhekumi" +"Esc" = "Esc" +"Essential" = "Olennainen" +"Every conflict has a choice." = "Jokaiselle ristiriidalle on valittu ratkaisu." +"Every top-level frame already starts a flow" = "Jokainen ylimmän tason kehys aloittaa jo kulun" +"Exact opacity (4 then 5: 45%)" = "Tarkka peittävyys (4 ja sitten 5: 45 %)" +"Exclude" = "Jätä pois" +"Excluded" = "Jätetty pois" +"Excluded from layout" = "Jätetty pois asettelusta" +"Excludes" = "Poissulkee" +"Exclusion" = "Poissulku" +"Exit focus view (Esc)" = "Poistu kohdistusnäkymästä (Esc)" +"Expand" = "Laajenna" +"Export" = "Vie" +"Export all" = "Vie kaikki" +"Export frames and layers as PNG, JPG, SVG or PDF" = "Vie kehyksiä ja tasoja PNG-, JPG-, SVG- tai PDF-muodossa" +"Export page as PNG" = "Vie sivu PNG-muodossa" +"Export settings" = "Vientiasetukset" +"Export variables" = "Vie muuttujat" +"Export {name}" = "Vie {name}" +"Export {n} layers" = { one = "Vie {n} taso", other = "Vie {n} tasoa" } +"Exported {file}" = "Viety: {file}" +"Exported {name}" = "Viety: {name}" +"Exported {name}; {n} kinds of content were left out" = { one = "Viety {name}; {n} sisältötyyppi jätettiin pois", other = "Viety {name}; {n} sisältötyyppiä jätettiin pois" } +"Exported {n} files" = { one = "Viety {n} tiedosto", other = "Viety {n} tiedostoa" } +"Export…" = "Vie…" +"Expose properties to instances" = "Tuo ominaisuudet ilmentymien käyttöön" +"Exposure" = "Valotus" +"Expression" = "Lauseke" +"Extend collection" = "Laajenna kokoelmaa" +"Extends \"{name}\": overridden values are marked" = "Laajentaa kokoelmaa ”{name}”: ohitetut arvot on merkitty" +"Eye" = "Silmä" +"F" = "F" +"Fade" = "Häivytys" +"False" = "Epätosi" +"Feb" = "helmik." +"Fetching from the server…" = "Haetaan palvelimelta…" +"FigCraft couldn't open its window: {error}" = "FigCraft ei voinut avata ikkunaansa: {error}" +"FigCraft doesn't support every feature of these files yet. The notes list what was left out or changed in the imported copy; the original file is unchanged." = "FigCraft ei vielä tue näiden tiedostojen kaikkia ominaisuuksia. Huomautukset kertovat, mitä tuodusta kopiosta jätettiin pois tai muutettiin. Alkuperäinen tiedosto säilyy ennallaan." +"File" = "Tiedosto" +"File name" = "Tiedostonimi" +"File not found" = "Tiedostoa ei löydy" +"File published" = "Tiedosto julkaistu" +"Files aren't end-to-end encrypted: the server's operator can read them." = "Tiedostoja ei salata päästä päähän. Palvelimen ylläpitäjä voi lukea ne." +"Files nobody opens for {n} days are deleted." = { one = "Tiedostot, joita kukaan ei avaa {n} päivään, poistetaan.", other = "Tiedostot, joita kukaan ei avaa {n} päivään, poistetaan." } +"Fill" = "Täyttö" +"Fill (fr)" = "Täytä (fr)" +"Fill container" = "Täytä säilö" +"Fill window" = "Täytä ikkuna" +"Fills" = "Täytöt" +"Fill|sizing" = "Täytä" +"Filter and sort" = "Suodata ja lajittele" +"Find" = "Etsi" +"Find and replace" = "Etsi ja korvaa" +"Find layers" = "Etsi tasoja" +"First" = "Ensimmäinen" +"First commit" = "Ensimmäinen muutos" +"First on top" = "Ensimmäinen päällimmäisenä" +"Fit" = "Sovita" +"Fit device to window" = "Sovita laite ikkunaan" +"Fit to contents" = "Sovita sisältöön" +"Fit to window" = "Sovita ikkunaan" +"Fit width" = "Sovita leveyteen" +"Fixed" = "Kiinteä" +"Fixed gap" = "Kiinteä väli" +"Fixed size" = "Kiinteä koko" +"Flatten" = "Yhdistä vektorimuodoksi" +"Flip" = "Peilaa" +"Flip horizontal" = "Peilaa vaakasuunnassa" +"Flip text to the other side" = "Siirrä teksti toiselle puolelle" +"Flip vertical" = "Peilaa pystysuunnassa" +"Flip width profile" = "Peilaa leveysprofiili" +"Flow starting point" = "Kulun aloituspiste" +"Flow starting points" = "Kulkujen aloituspisteet" +"Flows" = "Kulut" +"Focus view" = "Kohdistusnäkymä" +"Fold autosaves" = "Tiivistä automaattiset tallennukset" +"Folder: click to collapse" = "Kansio: supista napsauttamalla" +"Folder: click to expand" = "Kansio: laajenna napsauttamalla" +"Follow" = "Seuraa" +"Follow {name}" = "Seuraa käyttäjää {name}" +"Following" = "Seurataan" +"Following {name} · {key} to stop" = "Seurataan käyttäjää {name} · lopeta painamalla {key}" +"Font" = "Fontti" +"Font not installed" = "Fonttia ei ole asennettu" +"Font size" = "Fonttikoko" +"Font style" = "Fonttityyli" +"Forget on this computer" = "Unohda tällä tietokoneella" +"Four screens of a trail app with components and a prototype flow" = "Retkeilysovelluksen neljä näkymää, komponentit ja prototyypin kulku" +"Fractions" = "Murtoluvut" +"Frame" = "Kehys" +"Frame selection" = "Kehystä valinta" +"Frame tools" = "Kehystyökalut" +"Frames" = "Kehykset" +"From the library \"{name}\": read-only" = "Kirjastosta ”{name}”: vain luku" +"From {name}" = "Lähteestä {name}" +"From {server}" = "Palvelimelta {server}" +"Frost" = "Huurre" +"Gap" = "Väli" +"Gap between columns" = "Sarakkeiden väli" +"Gap between items" = "Kohteiden väli" +"Gap between rows" = "Rivien väli" +"Getting started" = "Aloittaminen" +"Give the address of a server open to the public, or a server's address with its key (`figcraft-server key` prints it)." = "Anna julkisen palvelimen osoite tai palvelimen osoite avaimineen (`figcraft-server key` tulostaa avaimen)." +"Glass" = "Lasi" +"Go to main component" = "Siirry pääkomponenttiin" +"Go to {name}" = "Siirry kohteeseen {name}" +"Grab bag" = "Sekalaiset" +"Gradient" = "Liukuväri" +"Gray" = "Harmaa" +"Green" = "Vihreä" +"Greyscale components and two example screens to sketch a flow with" = "Harmaasävykomponentit ja kaksi esimerkkinäkymää kulun luonnosteluun" +"Grid" = "Ruudukko" +"Grid ({size})" = "Ruudukko ({size})" +"Grid styles" = "Ruudukkotyylit" +"Grid {size}" = "Ruudukko {size}" +"Group" = "Ryhmä" +"Group selection" = "Ryhmittele valinta" +"Groups" = "Ryhmät" +"Gutter" = "Palstaväli" +"Hand tool" = "Käsityökalu" +"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" +"Help page" = "Ohjesivu" +"Here: {names}" = "Paikalla: {names}" +"Hidden" = "Kätketty" +"Hide contents" = "Kätke sisältö" +"Hide from publishing" = "Jätä julkaisematta" +"Hide section contents" = "Kätke osion sisältö" +"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" +"Hold Ctrl" = "Pidä Ctrl painettuna" +"Hold Z" = "Pidä Z painettuna" +"Hold ⌃ or ⌘" = "Pidä ⌃ tai ⌘ painettuna" +"Hold ⌥" = "Pidä ⌥ painettuna" +"Hold ⌥Z" = "Pidä ⌥Z painettuna" +"Home" = "Aloitus" +"Horizontal" = "Vaakasuunta" +"Horizontal align" = "Vaakatasaus" +"Horizontal hex" = "Vaakasuuntainen kuusikulmio" +"Horizontal padding" = "Vaakasuuntainen sisämarginaali" +"Horizontal spacing" = "Vaakavälistys" +"Hosted by {operator}" = "Ylläpitäjä: {operator}" +"Hover a layer to see its properties" = "Näytä tason ominaisuudet viemällä osoitin sen päälle" +"Hover other layers to measure the distance" = "Mittaa etäisyys viemällä osoitin muiden tasojen päälle" +"Hue" = "Sävy" +"Hug" = "Sovita sisältöön" +"Hug contents" = "Sovita sisältöön" +"Icon components on a 24 px grid with keylines and a size preview" = "Kuvakekomponentteja 24 px:n ruudukossa, apulinjat ja koon esikatselu" +"Icon set starter" = "Kuvakejoukon aloituspohja" +"If" = "Jos" +"If it keeps happening, please report it at {link} with this message." = "Jos ongelma jatkuu, ilmoita siitä osoitteessa {link} ja liitä tämä viesti mukaan." +"If {condition}" = "Jos {condition}" +"Ignore auto layout (absolute position)" = "Ohita automaattinen asettelu (absoluuttinen sijainti)" +"Image" = "Kuva" +"Images" = "Kuvat" +"Import" = "Tuo" +"Import path" = "Tuontipolku" +"Import plugin from manifest" = "Tuo liitännäinen manifestista" +"Import plugin from manifest…" = "Tuo liitännäinen manifestista…" +"Import report" = "Tuontiraportti" +"Import variables" = "Tuo muuttujat" +"Imported document" = "Tuotu asiakirja" +"Imported {name}" = "Tuotu: {name}" +"Imported {n} new variables, updated {updated}" = { one = "Tuotu {n} uusi muuttuja, päivitetty {updated}", other = "Tuotu {n} uutta muuttujaa, päivitetty {updated}" } +"Import…" = "Tuo…" +"In an instance, this comes from the main component" = "Ilmentymässä tämä arvo tulee pääkomponentista" +"In the browser you can join sessions the desktop app hosts and files on a FigCraft server. A page opened over https reaches other computers only through wss:// links (a server behind TLS)." = "Selaimessa voit liittyä työpöytäsovelluksen isännöimiin istuntoihin ja FigCraft-palvelimen tiedostoihin. https-sivu tavoittaa muut tietokoneet vain wss://-linkeillä (TLS:n takana oleva palvelin)." +"Include bounding box" = "Sisällytä rajauslaatikko" +"Include id attribute" = "Sisällytä id-määrite" +"Included" = "Sisällytetty" +"Included in layout" = "Sisällytetty asetteluun" +"Individual corners" = "Kulmat erikseen" +"Individual strokes" = "Reunaviivat erikseen" +"Ink" = "Muste" +"Inner shadow" = "Sisävarjo" +"Inside" = "Sisäpuolella" +"Inspect" = "Tarkastele" +"Inspect the layer under the pointer: size, colours, typography" = "Tarkastele osoittimen alla olevaa tasoa: koko, värit ja typografia" +"Install" = "Asenna" +"Install plugin" = "Asenna liitännäinen" +"Installed" = "Asennettu" +"Installed {name}" = "Asennettu: {name}" +"Installing…" = "Asennetaan…" +"Instance" = "Ilmentymä" +"Instance properties" = "Ilmentymän ominaisuudet" +"Instance swap" = "Ilmentymän vaihto" +"Instance swap (no other component)" = "Ilmentymän vaihto (ei muuta komponenttia)" +"Instances" = "Ilmentymät" +"Instant" = "Välitön" +"Intensity" = "Vahvuus" +"Interaction" = "Vuorovaikutus" +"Interactions" = "Vuorovaikutukset" +"Interface design and prototyping. A free, open-source, clean-room design tool written from scratch in Rust, part of the ArtCraft Crafting Apps." = "Käyttöliittymien suunnittelu ja prototyypit. Ilmainen avoimen lähdekoodin suunnittelutyökalu, joka on toteutettu riippumattomasti alusta alkaen Rustilla. Osa ArtCraft-sovellusperhettä." +"Intersect" = "Leikkaus" +"Intersects" = "Leikkaa" +"Invalid color" = "Virheellinen väri" +"Invite link" = "Kutsulinkki" +"Invite link copied" = "Kutsulinkki kopioitu" +"It asks permission to:" = "Se pyytää oikeutta:" +"Jan" = "tammik." +"Join" = "Liity" +"Join code copied" = "Liittymiskoodi kopioitu" +"Join code or invite link" = "Liittymiskoodi tai kutsulinkki" +"Join live session" = "Liity reaaliaikaiseen istuntoon" +"Join us on Discord" = "Liity yhteisöömme Discordissa" +"Join with a code" = "Liity koodilla" +"Joined" = "Liitytty" +"Joined {file}" = "Liitytty tiedostoon {file}" +"Joining…" = "Liitytään…" +"Join|stroke" = "Liitos" +"Jul" = "heinäk." +"Jun" = "kesäk." +"Just now" = "Juuri nyt" +"Justify" = "Tasaa molemmat reunat" +"Keep appearance" = "Säilytä ulkoasu" +"Keep appearance converts every color value to {profile} so colors look the same. Keep values leaves the numbers as they are, so colors shift." = "Säilytä ulkoasu muuntaa kaikki väriarvot profiiliin {profile}, jotta värit näyttävät samoilta. Säilytä arvot jättää lukuarvot ennalleen, jolloin värit muuttuvat." +"Keep tool selected after use" = "Pidä työkalu valittuna käytön jälkeen" +"Keep values" = "Säilytä arvot" +"Key/Gamepad" = "Näppäin/peliohjain" +"Key/Gamepad {key}" = "Näppäin/peliohjain {key}" +"Keyboard shortcuts" = "Pikanäppäimet" +"Kits" = "Paketit" +"Landing page" = "Laskeutumissivu" +"Language" = "Kieli" +"Lasso" = "Lasso" +"Last" = "Viimeinen" +"Last change: {note}" = "Viimeisin muutos: {note}" +"Last commit" = "Viimeisin muutos" +"Last modified" = "Viimeksi muokattu" +"Last on top" = "Viimeinen päällimmäisenä" +"Layer blur" = "Tason sumennus" +"Layer name" = "Tason nimi" +"Layers" = "Tasot" +"Layers in an instance take their position from the main component" = "Ilmentymän tasot saavat sijaintinsa pääkomponentista" +"Layout" = "Asettelu" +"Layout grid" = "Asetteluruudukko" +"Layout grid settings" = "Asetteluruudukon asetukset" +"Layout grids" = "Asetteluruudukot" +"Leave session" = "Poistu istunnosta" +"Leaves" = "Lehdet" +"Left" = "Vasen" +"Left & right" = "Vasen ja oikea" +"Left side" = "Vasen puoli" +"Left side width" = "Vasemman puolen leveys" +"Letter case" = "Kirjainkoko" +"Letter spacing" = "Merkkiväli" +"Libraries" = "Kirjastot" +"Libraries in this file" = "Tämän tiedoston kirjastot" +"Library updates available" = "Kirjastopäivityksiä saatavilla" +"Library: click to collapse" = "Kirjasto: supista napsauttamalla" +"Library: click to expand" = "Kirjasto: laajenna napsauttamalla" +"Library: its variables are read-only here; edit them in the library file and publish it" = "Kirjasto: sen muuttujat ovat tässä vain luettavissa. Muokkaa niitä kirjastotiedostossa ja julkaise se." +"Light" = "Vaalea" +"Lighten" = "Vaalenna" +"Light|glass" = "Valo" +"Line" = "Viiva" +"Line arrow" = "Viivanuoli" +"Line delta" = "Rivimuutos" +"Line height" = "Rivikorkeus" +"Linear" = "Lineaarinen" +"Lines" = "Viivat" +"Lines +/−" = "Rivit +/−" +"Lines added" = "Lisätyt rivit" +"Lines deleted" = "Poistetut rivit" +"Link" = "Linkki" +"Link copied" = "Linkki kopioitu" +"Link docs, code or tickets for developers." = "Linkitä dokumentaatiota, koodia tai tehtäviä kehittäjille." +"Link frames on the Prototype tab, then present the flow" = "Linkitä kehykset Prototyyppi-välilehdellä ja esitä sitten kulku" +"Link this component to its code component: instances then show your real snippet with their values." = "Yhdistä tämä komponentti sen koodikomponenttiin. Ilmentymät näyttävät sitten todellisen koodikatkelmasi omilla arvoillaan." +"List spacing" = "Luettelon välistys" +"List style" = "Luettelotyyli" +"Live session" = "Reaaliaikainen istunto" +"Live · {people}" = "Reaaliaikainen · {people}" +"Local components" = "Paikalliset komponentit" +"Local network" = "Paikallisverkko" +"Local styles" = "Paikalliset tyylit" +"Local variables" = "Paikalliset muuttujat" +"Lock/Unlock" = "Lukitse/avaa lukitus" +"Locked" = "Lukittu" +"Lowercase" = "Pienet kirjaimet" +"Luminance" = "Luminanssi" +"Luminosity" = "Valoisuus" +"Main" = "Päähaara" +"Main component deleted" = "Pääkomponentti poistettu" +"Main menu" = "Päävalikko" +"Make a component" = "Luo komponentti" +"Make slot" = "Luo sisältöpaikka" +"Mar" = "maalisk." +"Margin" = "Marginaali" +"Mark all as read" = "Merkitse kaikki luetuiksi" +"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)" +"Max H" = "Enimmäiskorkeus" +"Max W" = "Enimmäisleveys" +"Max lines" = "Rivien enimmäismäärä" +"Maximize" = "Suurenna" +"May" = "toukok." +"Measure to the selection" = "Mittaa valintaan" +"Merge into main" = "Yhdistä päähaaraan" +"Merge regions" = "Yhdistä alueet" +"Merge the branch into the main file (one undo step) and archive it" = "Yhdistä haara päätiedostoon (yksi kumottava toiminto) ja arkistoi se" +"Merged" = "Yhdistetty" +"Merged PRs" = "Yhdistetyt PR:t" +"Min H" = "Vähimmäiskorkeus" +"Min W" = "Vähimmäisleveys" +"Minimize" = "Pienennä" +"Mirror angle" = "Peilaa kulma" +"Mirror angle and length" = "Peilaa kulma ja pituus" +"Mirroring" = "Peilaus" +"Missing" = "Puuttuu" +"Missing fonts" = "Puuttuvat fontit" +"Missing layer" = "Puuttuva taso" +"Missing style" = "Puuttuva tyyli" +"Missing variable" = "Puuttuva muuttuja" +"Miter" = "Jiiri" +"Miter angle" = "Jiirikulma" +"Mixed" = "Useita arvoja" +"Mixed fonts" = "Useita fontteja" +"Mixed interactions: click + to replace them" = "Vaihtelevia vuorovaikutuksia: korvaa napsauttamalla +" +"Mobile app" = "Mobiilisovellus" +"Mode" = "Tila" +"Model" = "Malli" +"Models" = "Mallit" +"Mono" = "Yksivärinen" +"More" = "Lisää" +"More instance actions" = "Lisää ilmentymän toimintoja" +"Mouse down" = "Hiiren painallus" +"Mouse enter" = "Osoitin saapuu" +"Mouse leave" = "Osoitin poistuu" +"Mouse up" = "Hiiren vapautus" +"Move" = "Siirrä" +"Move along the toolbar or the layer rows" = "Siirry työkalurivillä tai tasoriveillä" +"Move column left" = "Siirrä sarake vasemmalle" +"Move column right" = "Siirrä sarake oikealle" +"Move down" = "Siirrä alas" +"Move in" = "Siirry sisään" +"Move out" = "Siirry ulos" +"Move tools" = "Siirtotyökalut" +"Move up" = "Siirrä ylös" +"Moved to {name}" = "Siirretty kohteeseen {name}" +"Multi" = "Monivärinen" +"Multiply" = "Kerro" +"Muted" = "Hillitty" +"Name" = "Nimi" +"Name (A–Z)" = "Nimi (A–Z)" +"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" +"Next or previous control in the focused region" = "Seuraava tai edellinen säädin kohdistetulla alueella" +"Next ready for dev" = "Seuraava kehitysvalmis" +"Next size option" = "Seuraava kokovaihtoehto" +"No assets: no export settings, icons or images. Add export settings in the Design tab." = "Ei resursseja: ei vientiasetuksia, kuvakkeita tai kuvia. Lisää vientiasetuksia Suunnittelu-välilehdellä." +"No boolean variables fit here." = "Tähän ei sovi totuusarvomuuttujia." +"No boolean variables yet. Create them in Local variables." = "Ei vielä totuusarvomuuttujia. Luo niitä Paikalliset muuttujat -näkymässä." +"No branches. A branch is a copy of the file to try changes in, then review and merge." = "Ei haaroja. Haara on tiedoston kopio, jossa voi kokeilla muutoksia ja sitten tarkistaa ja yhdistää ne." +"No category" = "Ei luokkaa" +"No changes" = "Ei muutoksia" +"No color styles" = "Ei värityylejä" +"No colour variables fit here." = "Tähän ei sovi värimuuttujia." +"No colour variables yet. Create them in Local variables." = "Ei vielä värimuuttujia. Luo niitä Paikalliset muuttujat -näkymässä." +"No comments yet" = "Ei vielä kommentteja" +"No compatibility warnings reported" = "Yhteensopivuusvaroituksia ei ilmoitettu" +"No components yet. Select layers and press {keys}." = "Ei vielä komponentteja. Valitse tasoja ja paina {keys}." +"No conflicts." = "Ei ristiriitoja." +"No contributor data was built into this copy." = "Tähän versioon ei ole sisällytetty tekijätietoja." +"No device" = "Ei laitetta" +"No files or templates match your search" = "Hakua vastaavia tiedostoja tai malleja ei löydy" +"No fonts match" = "Vastaavia fontteja ei löydy" +"No join code here (the server is older than join codes, or this address can't be put in one): share the link instead." = "Liittymiskoodia ei ole (palvelin on liittymiskoodeja vanhempi tai tätä osoitetta ei voi sisällyttää koodiin). Jaa linkki sen sijaan." +"No layer changes to show." = "Ei näytettäviä tasomuutoksia." +"No libraries yet. Add a .figcraft file that was published as a library, or paste a team library's link." = "Ei vielä kirjastoja. Lisää kirjastona julkaistu .figcraft-tiedosto tai liitä tiimikirjaston linkki." +"No list" = "Ei luetteloa" +"No matching comments" = "Vastaavia kommentteja ei löydy" +"No matching components" = "Vastaavia komponentteja ei löydy" +"No matching layers" = "Vastaavia tasoja ei löydy" +"No matching styles" = "Vastaavia tyylejä ei löydy" +"No matching variables" = "Vastaavia muuttujia ei löydy" +"No mirroring" = "Ei peilausta" +"No model credits were built into this copy." = "Tähän versioon ei ole sisällytetty mallien tekijätietoja." +"No number variables fit here." = "Tähän ei sovi lukumuuttujia." +"No number variables yet. Create them in Local variables." = "Ei vielä lukumuuttujia. Luo niitä Paikalliset muuttujat -näkymässä." +"No other pages" = "Ei muita sivuja" +"No paints" = "Ei täyttöjä" +"No plugins on {server}." = "Palvelimella {server} ei ole liitännäisiä." +"No plugins yet. Import a development plugin from its figcraft-plugin.json." = "Ei vielä liitännäisiä. Tuo kehitysliitännäinen sen figcraft-plugin.json-tiedostosta." +"No preferred components" = "Ei ensisijaisia komponentteja" +"No preset" = "Ei esiasetusta" +"No saved versions yet. Save a version to mark a point in your work you can come back to." = "Ei vielä tallennettuja versioita. Tallenna versio, jotta voit palata tähän työvaiheeseen myöhemmin." +"No scrolling" = "Ei vieritystä" +"No shortcuts match" = "Vastaavia pikanäppäimiä ei löydy" +"No status" = "Ei tilaa" +"No string variables fit here." = "Tähän ei sovi merkkijonomuuttujia." +"No string variables yet. Create them in Local variables." = "Ei vielä merkkijonomuuttujia. Luo niitä Paikalliset muuttujat -näkymässä." +"No styles yet." = "Ei vielä tyylejä." +"No styles yet. Click + to create one from the selection." = "Ei vielä tyylejä. Luo tyyli valinnasta napsauttamalla +." +"No templates match your search" = "Hakua vastaavia malleja ei löydy" +"No text styles yet. Click + to create one from the selected text." = "Ei vielä tekstityylejä. Luo tyyli valitusta tekstistä napsauttamalla +." +"No variables fit here." = "Tähän ei sovi muuttujia." +"No variables yet. Create them in Local variables." = "Ei vielä muuttujia. Luo niitä Paikalliset muuttujat -näkymässä." +"Nobody else is here yet" = "Kukaan muu ei ole vielä paikalla" +"Noise" = "Kohina" +"None" = "Ei mitään" +"None." = "Ei mitään." +"Normal" = "Normaali" +"Not bound" = "Ei sidottu" +"Not in a live session" = "Ei reaaliaikaisessa istunnossa" +"Not in this font" = "Ei tässä fontissa" +"Not properties, kept as written: {names}" = "Eivät ole ominaisuuksia, säilytetään sellaisinaan: {names}" +"Not read yet" = "Ei vielä luettu" +"Not shared" = "Ei jaettu" +"Note {n}" = "Huomautus {n}" +"Nothing changed since this was marked ready for dev." = "Mikään ei ole muuttunut kehitysvalmiiksi merkitsemisen jälkeen." +"Nothing here yet. Set a frame's status to list it." = "Ei vielä sisältöä. Lisää kehys luetteloon asettamalla sen tila." +"Nothing online yet. Use Share on a file to put it online and it is listed here." = "Ei vielä verkkotiedostoja. Julkaise tiedosto verkkoon Jaa-toiminnolla, niin se näkyy tässä." +"Nothing to export" = "Ei vietävää" +"Nov" = "marrask." +"Now" = "Nyt" +"Nudge (Shift: big nudge)" = "Siirrä askel (Shift: suuri askel)" +"Nudge amount" = "Siirtoaskeleen koko" +"Number" = "Luku" +"Number ↑" = "Luku ↑" +"Number ↓" = "Luku ↓" +"Numbered list" = "Numeroitu luettelo" +"Object" = "Objekti" +"Oct" = "lokak." +"Off" = "Ei käytössä" +"Offered first when filling the slot (any content is allowed)" = "Ehdotetaan ensin sisältöpaikkaa täytettäessä (kaikki sisältö sallitaan)" +"Offered first when swapping the instance (any component is allowed)" = "Ehdotetaan ensin ilmentymää vaihdettaessa (kaikki komponentit sallitaan)" +"Offset" = "Siirtymä" +"Oldest first" = "Vanhimmat ensin" +"On click" = "Napsautettaessa" +"On drag" = "Vedettäessä" +"On this page" = "Tällä sivulla" +"One announcement in five common sizes: square, portrait, story, link and banner" = "Yksi ilmoitus viidessä tavallisessa koossa: neliö, pysty, tarina, linkki ja banneri" +"Online" = "Verkossa" +"Online files" = "Verkkotiedostot" +"Online sharing is off: files are shared on the local network only." = "Verkkojakaminen on pois käytöstä. Tiedostoja jaetaan vain lähiverkossa." +"Only the first changed layers are listed." = "Vain ensimmäiset muuttuneet tasot näytetään." +"Only the first {n} changed layers are listed." = "Vain ensimmäiset {n} muuttunutta tasoa näytetään." +"Only your threads" = "Vain omat keskustelut" +"Opacity" = "Peittävyys" +"Opacity & blend" = "Peittävyys ja sekoitus" +"Opacity 10%…90%, 0: 100%" = "Peittävyys 10 %…90 %, 0: 100 %" +"Open" = "Avaa" +"Open .figcraft, .svg or images" = "Avaa .figcraft-, .svg- tai kuvatiedostoja" +"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" +"Open link" = "Avaa linkki" +"Open link {url}" = "Avaa linkki {url}" +"Open overlay" = "Avaa peittokuva" +"Open prototype preview" = "Avaa prototyypin esikatselu" +"Open variables ({count})" = "Avaa muuttujat ({count})" +"Opened as a new document" = "Avattu uutena asiakirjana" +"Opened “{name}” in a new tab" = "”{name}” avattu uudessa välilehdessä" +"Opening the file…" = "Avataan tiedostoa…" +"Opening {n} files as new files" = { one = "Avataan uutena tiedostona", other = "Avataan uusia tiedostoja: {n}" } +"Open|comments" = "Avoin" +"Options" = "Asetukset" +"Orange" = "Oranssi" +"Order" = "Järjestys" +"Other" = "Muu" +"Other apps may not open every .fig file exported here." = "Muut sovellukset eivät välttämättä avaa kaikkia tässä vietyjä .fig-tiedostoja." +"Others lose access and keep their copy; the file stays on the server for you" = "Muiden käyttöoikeus päättyy, ja he säilyttävät kopionsa. Tiedosto jää palvelimelle käyttöösi." +"Others type this under Share ▸ Join with a code" = "Muut kirjoittavat tämän kohtaan Jaa ▸ Liity koodilla" +"Outline stroke" = "Muunna reunaviiva poluksi" +"Outline text" = "Muunna teksti poluiksi" +"Outlines" = "Ääriviivat" +"Outside" = "Ulkopuolella" +"Overflow" = "Ylivuoto" +"Overlay" = "Peittokuva" +"Overlay position" = "Peittokuvan sijainti" +"Overridden here (the collection it extends has another value)" = "Ohitettu tässä (laajennetussa kokoelmassa on toinen arvo)" +"Overrides" = "Ohitukset" +"Owner token" = "Omistajan tunniste" +"Owner token copied" = "Omistajan tunniste kopioitu" +"PDF print options" = "PDF-tulostusasetukset" +"PDF print settings" = "PDF-tulostusasetukset" +"Padding" = "Sisämarginaali" +"Page" = "Sivu" +"Page deleted" = "Sivu poistettu" +"Pages" = "Sivut" +"Pages added to the document" = "Sivut lisätty asiakirjaan" +"Pages: {pages}" = "Sivut: {pages}" +"Paint bucket" = "Maalipurkki" +"Pan the canvas" = "Siirrä piirtoaluetta" +"Pan with nothing selected (Shift: farther)" = "Siirrä näkymää ilman valintaa (Shift: pidemmälle)" +"Panels" = "Paneelit" +"Paper" = "Paperi" +"Paragraph indent" = "Kappaleen sisennys" +"Paragraph indent applies to left-aligned text" = "Kappaleen sisennys koskee vasemmalle tasattua tekstiä" +"Paragraph spacing" = "Kappaleväli" +"Pass through" = "Läpivienti" +"Paste a link (https://…)" = "Liitä linkki (https://…)" +"Paste a team library link (a view link)" = "Liitä tiimikirjaston linkki (katselulinkki)" +"Paste here" = "Liitä tähän" +"Paste to replace" = "Liitä korvaten" +"Pasted design layers; {n} kinds of content were left out" = { one = "Suunnittelutasot liitetty; {n} sisältötyyppi jätettiin pois", other = "Suunnittelutasot liitetty; {n} sisältötyyppiä jätettiin pois" } +"Pattern" = "Kuvio" +"Pattern alignment" = "Kuvion tasaus" +"Pattern scale" = "Kuvion skaalaus" +"Pattern source" = "Kuvion lähde" +"Pen" = "Kynä" +"Pencil" = "Lyijykynä" +"People in this file" = "Tämän tiedoston käyttäjät" +"People join from anywhere with a code. The file is kept on {server} while you share it." = "Käyttäjät voivat liittyä koodilla mistä tahansa. Tiedosto säilytetään palvelimella {server} jakamisen ajan." +"People on your network join with a code. Nothing goes through a server." = "Lähiverkkosi käyttäjät liittyvät koodilla. Mitään ei kulje palvelimen kautta." +"Phone" = "Puhelin" +"Pick color" = "Poimi väri" +"Pick color (I)" = "Poimi väri (I)" +"Pick one font family first" = "Valitse ensin yksi fonttiperhe" +"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" +"Place a shape or frame (its tool chosen)" = "Sijoita muoto tai kehys (sen työkalu valittuna)" +"Place automatically" = "Sijoita automaattisesti" +"Place image" = "Sijoita kuva" +"Placed on the current page" = "Sijoitettu nykyiselle sivulle" +"Playground" = "Kokeilualue" +"Plugin" = "Liitännäinen" +"Plugins" = "Liitännäiset" +"Plugins & widgets" = "Liitännäiset ja pienoisohjelmat" +"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" +"Polygons" = "Monikulmiot" +"Port" = "Portti" +"Position" = "Sijainti" +"Post" = "Lähetä" +"Preferences" = "Asetukset" +"Preferred" = "Ensisijainen" +"Present" = "Esitä" +"Present or preview" = "Esitä tai esikatsele" +"Present this flow" = "Esitä tämä kulku" +"Presentation" = "Esitys" +"Presentation slides" = "Esitysdiat" +"Presenting: comments" = "Esitys: kommentit" +"Presenting: cycle the size options" = "Esitys: kierrä kokovaihtoehtoja" +"Presenting: full screen" = "Esitys: koko näyttö" +"Presenting: next frame" = "Esitys: seuraava kehys" +"Presenting: previous frame" = "Esitys: edellinen kehys" +"Presenting: restart" = "Esitys: aloita uudelleen" +"Preserve scroll position" = "Säilytä vierityksen sijainti" +"Presets" = "Esiasetukset" +"Press a key…" = "Paina näppäintä…" +"Preview" = "Esikatselu" +"Preview this flow" = "Esikatsele tätä kulkua" +"Preview this frame" = "Esikatsele tätä kehystä" +"Previous frame" = "Edellinen kehys" +"Previous ready for dev" = "Edellinen kehitysvalmis" +"Progressive" = "Asteittainen" +"Properties" = "Ominaisuudet" +"Prototype" = "Prototyyppi" +"Prototype preview" = "Prototyypin esikatselu" +"Prototype settings" = "Prototyypin asetukset" +"Publish" = "Julkaise" +"Publish changes" = "Julkaise muutokset" +"Publish to another server…" = "Julkaise toiselle palvelimelle…" +"Publish to server" = "Julkaise palvelimelle" +"Published version {v}" = "Julkaistu versio {v}" +"Published version {v} to the server" = "Versio {v} julkaistu palvelimelle" +"Published version {v}: save the file so other files can use it" = "Versio {v} julkaistu: tallenna tiedosto, jotta muut tiedostot voivat käyttää sitä" +"Published · {people}" = "Julkaistu · {people}" +"Publishing to a server needs the desktop app; in the browser you can join a server's files" = "Palvelimelle julkaiseminen vaatii työpöytäsovelluksen. Selaimessa voit liittyä palvelimen tiedostoihin." +"Publishing with the server's key." = "Julkaistaan palvelimen avaimella." +"Publishing…" = "Julkaistaan…" +"Push" = "Työntö" +"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" +"Ratio" = "Suhde" +"Reading the library…" = "Luetaan kirjastoa…" +"Ready for dev" = "Kehitysvalmis" +"Real name" = "Oikea nimi" +"Recent" = "Viimeisimmät" +"Recent files" = "Viimeksi avatut tiedostot" +"Recents" = "Viimeisimmät" +"Recovered {n} unsaved files after the app quit unexpectedly: save them to keep them" = { one = "Tallentamaton tiedosto palautettiin sovelluksen sulkeuduttua odottamatta. Tallenna se säilyttääksesi sen.", other = "Palautettu {n} tallentamatonta tiedostoa sovelluksen sulkeuduttua odottamatta. Tallenna ne säilyttääksesi ne." } +"Rectangle" = "Suorakulmio" +"Rectangles" = "Suorakulmiot" +"Red" = "Punainen" +"Reduce motion" = "Vähennä liikettä" +"Refraction" = "Taittuminen" +"Regular expression" = "Säännöllinen lauseke" +"Remove" = "Poista" +"Remove all" = "Poista kaikki" +"Remove annotation" = "Poista huomautus" +"Remove auto layout" = "Poista automaattinen asettelu" +"Remove dev resource" = "Poista kehittäjäresurssi" +"Remove export setting" = "Poista vientiasetus" +"Remove from recents" = "Poista viimeisimmistä" +"Remove from server" = "Poista palvelimelta" +"Remove from server…" = "Poista palvelimelta…" +"Remove guide" = "Poista apuviiva" +"Remove interaction" = "Poista vuorovaikutus" +"Remove library from this file" = "Poista kirjasto tästä tiedostosta" +"Remove min and max" = "Poista vähimmäis- ja enimmäisrajat" +"Remove min and max height" = "Poista korkeuden vähimmäis- ja enimmäisrajat" +"Remove min and max width" = "Poista leveyden vähimmäis- ja enimmäisrajat" +"Remove regions" = "Poista alueet" +"Remove slot" = "Poista sisältöpaikka" +"Remove starting point" = "Poista aloituspiste" +"Remove stop" = "Poista liukuvärin piste" +"Remove text from path" = "Poista teksti polulta" +"Remove the else and its actions" = "Poista muuten-haara ja sen toiminnot" +"Remove this condition and its actions" = "Poista tämä ehto ja sen toiminnot" +"Remove this file from the server? It can't be shared or opened from there again. Your copy stays open here." = "Poistetaanko tämä tiedosto palvelimelta? Sitä ei voi enää jakaa tai avata sieltä. Oma kopiosi pysyy auki tässä." +"Remove {name} from session" = "Poista {name} istunnosta" +"Remove {name} from {server}?" = "Poistetaanko {name} palvelimelta {server}?" +"Removed" = "Poistettu" +"Removed from the server" = "Poistettu palvelimelta" +"Removed since it was marked" = "Poistettu merkitsemisen jälkeen" +"Rename" = "Nimeä uudelleen" +"Rename duplicated layers" = "Nimeä monistetut tasot uudelleen" +"Rename folder" = "Nimeä kansio uudelleen" +"Rename group" = "Nimeä ryhmä uudelleen" +"Rename layers" = "Nimeä tasot uudelleen" +"Rename mode" = "Nimeä tila uudelleen" +"Rename on the server" = "Nimeä uudelleen palvelimella" +"Rename on the server…" = "Nimeä uudelleen palvelimella…" +"Rename page" = "Nimeä sivu uudelleen" +"Rename to" = "Uusi nimi" +"Replace all" = "Korvaa kaikki" +"Replace mixed export settings" = "Korvaa vaihtelevat vientiasetukset" +"Replace with" = "Korvaa kohteella" +"Replace with a new interaction" = "Korvaa uudella vuorovaikutuksella" +"Reply" = "Vastaa" +"Report" = "Ilmoita" +"Report this file…" = "Ilmoita tästä tiedostosta…" +"Reset" = "Palauta" +"Reset adjustments" = "Palauta säädöt" +"Reset all changes" = "Palauta kaikki muutokset" +"Reset all overrides on this layer" = "Palauta kaikki tämän tason ohitukset" +"Reset overrides" = "Palauta ohitukset" +"Reset slot" = "Palauta sisältöpaikka" +"Reset this override" = "Palauta tämä ohitus" +"Reset to style" = "Palauta tyylin arvo" +"Reset to the inherited value" = "Palauta peritty arvo" +"Resize by the nudge amount" = "Muuta kokoa siirtoaskeleen verran" +"Resize frames to the device's screen (or the window without a device): their constraints and auto layout respond" = "Sovita kehysten koko laitteen näyttöön (tai ikkunaan ilman laitetta). Rajoitteet ja automaattinen asettelu mukautuvat." +"Resize to fit" = "Sovita koko" +"Resizing" = "Koon muuttaminen" +"Resolve" = "Ratkaise" +"Resolve change" = "Ratkaise muutos" +"Resolved" = "Ratkaistu" +"Resources" = "Resurssit" +"Responsive" = "Mukautuva" +"Restart" = "Aloita uudelleen" +"Restore" = "Palauta" +"Restore main component" = "Palauta pääkomponentti" +"Restore this version" = "Palauta tämä versio" +"Restored “{name}”" = "”{name}” palautettu" +"Reverse the order" = "Käännä järjestys" +"Reversed triangle" = "Käänteinen kolmio" +"Review and merge" = "Tarkista ja yhdistä" +"Ribbon" = "Valintanauha" +"Right" = "Oikea" +"Right side" = "Oikea puoli" +"Right side width" = "Oikean puolen leveys" +"Rotate 90°" = "Kierrä 90°" +"Rotate 90° right" = "Kierrä 90° oikealle" +"Rotate to landscape" = "Käännä vaakasuuntaan" +"Rotate to portrait" = "Käännä pystysuuntaan" +"Rotation" = "Kierto" +"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" +"Row span" = "Riviulottuvuus" +"Rows" = "Rivit" +"Rows {count}" = "Rivejä: {count}" +"Rulers" = "Viivaimet" +"Run" = "Suorita" +"Run {label}" = "Suorita {label}" +"SHA-256: {sha}…" = "SHA-256: {sha}…" +"Same as layer" = "Sama kuin tasolla" +"Saturation" = "Kylläisyys" +"Save" = "Tallenna" +"Save as" = "Tallenna nimellä" +"Save as preset…" = "Tallenna esiasetuksena…" +"Save files automatically" = "Tallenna tiedostot automaattisesti" +"Save version {key}" = "Tallenna versio {key}" +"Saved" = "Tallennettu" +"Saved {name}" = "Tallennettu: {name}" +"Say something" = "Kirjoita viesti" +"Scale" = "Skaalaus" +"Scatter" = "Hajonta" +"Scoping: where pickers offer it" = "Käyttöalue: missä valitsimet tarjoavat tätä" +"Screen" = "Rasteri" +"Scroll behavior" = "Vierityksen toiminta" +"Scroll to" = "Vieritä kohteeseen" +"Scroll to layer" = "Vieritä tasoon" +"Scroll with parent" = "Vieritä emotason mukana" +"Search" = "Etsi" +"Search actions, layers and tools" = "Etsi toimintoja, tasoja ja työkaluja" +"Search assets" = "Etsi resursseja" +"Search comments" = "Etsi kommentteja" +"Search files and templates" = "Etsi tiedostoja ja malleja" +"Search fonts" = "Etsi fontteja" +"Search layers" = "Etsi tasoja" +"Search plugins" = "Etsi liitännäisiä" +"Search results" = "Hakutulokset" +"Search shortcuts" = "Etsi pikanäppäimiä" +"Search templates" = "Etsi malleja" +"Section" = "Osio" +"Section color" = "Osion väri" +"Sections" = "Osiot" +"See all templates" = "Näytä kaikki mallit" +"Select a frame and add a starting point to present it as a flow." = "Valitse kehys ja lisää aloituspiste, jotta voit esittää sen kulkuna." +"Select a frame to preview it" = "Valitse kehys esikatseltavaksi" +"Select a layer to see its properties and code" = "Valitse taso, jotta näet sen ominaisuudet ja koodin" +"Select all" = "Valitse kaikki" +"Select all with same" = "Valitse kaikki samalla ominaisuudella" +"Select layer" = "Valitse taso" +"Select layers and they space themselves as their content changes" = "Valitse tasoja, niin niiden välit mukautuvat sisällön muuttuessa" +"Select layers to apply the style to" = "Valitse tasot, joihin tyyliä käytetään" +"Select layers with this color" = "Valitse tämänväriset tasot" +"Select overlapping shapes" = "Valitse päällekkäiset muodot" +"Select points to edit their position, radius and mirroring" = "Valitse pisteitä muokataksesi niiden sijaintia, sädettä ja peilausta" +"Select slot" = "Valitse sisältöpaikka" +"Selection" = "Valinta" +"Selection box, {w} by {h} at {x}, {y}: arrows move it, Shift+arrows or {cmd}+arrows size it, Enter selects, Escape cancels" = "Valintalaatikko, {w} × {h} kohdassa {x}, {y}: nuolet siirtävät, Shift+nuolet tai {cmd}+nuolet muuttavat kokoa, Enter valitsee, Escape peruuttaa" +"Selection box: arrows move it, Shift+arrows or {cmd}+arrows size it, Enter selects, Escape cancels" = "Valintalaatikko: nuolet siirtävät, Shift+nuolet tai {cmd}+nuolet muuttavat kokoa, Enter valitsee, Escape peruuttaa" +"Selection colors" = "Valinnan värit" +"Send backward" = "Vie taaksepäin" +"Send report" = "Lähetä ilmoitus" +"Send to back" = "Vie takimmaiseksi" +"Sep" = "syysk." +"Server address" = "Palvelimen osoite" +"Server address (ws://host:47900/)" = "Palvelimen osoite (ws://host:47900/)" +"Server for online sharing" = "Verkkojakamisen palvelin" +"Set as default" = "Aseta oletukseksi" +"Set variable" = "Aseta muuttuja" +"Set variable mode" = "Aseta muuttujan tila" +"Set variable {name}" = "Aseta muuttuja {name}" +"Set {name} to {value}" = "Aseta {name} arvoon {value}" +"Shadows" = "Varjot" +"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" +"Share: live session, export" = "Jaa: reaaliaikainen istunto, vienti" +"Shared" = "Jaettu" +"Shared: copy its code from the file's menu" = "Jaettu: kopioi koodi tiedoston valikosta" +"Sharing a file needs the desktop app; here you can join one." = "Tiedoston jakaminen vaatii työpöytäsovelluksen. Tässä voit liittyä jaettuun tiedostoon." +"Sharing on the local network (online sharing is off: no server is set in Preferences)." = "Jaetaan lähiverkossa (verkkojakaminen on pois käytöstä: asetuksissa ei ole palvelinta)." +"Sharing on your network · {people}" = "Jaetaan lähiverkossasi · {people}" +"Sharing online · {people}" = "Jaetaan verkossa · {people}" +"Sharing stopped: the file stays on the server, open only to you" = "Jakaminen lopetettu: tiedosto jää palvelimelle vain sinun käyttöösi" +"Show" = "Näytä" +"Show all templates" = "Näytä kaikki mallit" +"Show as grid" = "Näytä ruudukkona" +"Show as list" = "Näytä luettelona" +"Show autosaves" = "Näytä automaattiset tallennukset" +"Show behind transparent areas" = "Näytä läpinäkyvien alueiden takana" +"Show comments (Shift+C)" = "Näytä kommentit (Shift+C)" +"Show device frame" = "Näytä laitteen kehys" +"Show dimensions on objects" = "Näytä objektien mitat" +"Show hotspot hints" = "Näytä aktiivisten kohtien vihjeet" +"Show in Explorer" = "Näytä Resurssienhallinnassa" +"Show in Finder" = "Näytä Finderissa" +"Show in folder" = "Näytä kansiossa" +"Show only this on the canvas" = "Näytä piirtoalueella vain tämä" +"Show open" = "Näytä avoimet" +"Show section contents" = "Näytä osion sisältö" +"Show this layer" = "Näytä tämä taso" +"Show usage" = "Näytä käyttö" +"Show/Hide" = "Näytä/kätke" +"Show/Hide UI" = "Näytä/kätke käyttöliittymä" +"Side by side" = "Vierekkäin" +"Sidebar" = "Sivupalkki" +"Simplify stroke" = "Yksinkertaista reunaviiva" +"Size" = "Koko" +"Size jitter" = "Koon vaihtelu" +"Skip ink" = "Väistä merkkien viivoja" +"Slashed zero" = "Viivallinen nolla" +"Slice" = "Viipale" +"Slices" = "Viipaleet" +"Slide in" = "Liu'u sisään" +"Slide out" = "Liu'u ulos" +"Slot" = "Sisältöpaikka" +"Slot content" = "Sisältöpaikan sisältö" +"Small capitals" = "Kapiteelit" +"Small caps" = "Kapiteelit" +"Smart animate" = "Älykäs animointi" +"Snap to pixel grid" = "Tartu pikseliruudukkoon" +"Social and slides" = "Some ja diat" +"Social media" = "Sosiaalinen media" +"Social post sizes" = "Somejulkaisujen koot" +"Soft light" = "Pehmeä valo" +"Solid" = "Yksivärinen" +"Some variants have the same property values: give each one a unique combination" = "Joillakin varianteilla on samat ominaisuusarvot. Anna kullekin yksilöllinen yhdistelmä." +"Sort" = "Lajittele" +"Sort by" = "Lajitteluperuste" +"Sort by date" = "Lajittele päivämäärän mukaan" +"Sort by page" = "Lajittele sivun mukaan" +"Sort by {column}" = "Lajittele sarakkeen {column} mukaan" +"Space around" = "Tila ympärillä" +"Space between" = "Tila välissä" +"Space drag" = "Space drag" +"Space evenly" = "Tasavälein" +"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" +"Stars" = "Tähdet" +"Start" = "Alku" +"Start ascending from / stop descending at" = "Nousevan osan alku / laskevan osan loppu" +"Start from a blank canvas" = "Aloita tyhjältä piirtoalueelta" +"Start from a template" = "Aloita mallista" +"Start on path" = "Aloituskohta polulla" +"Start on the Home screen" = "Aloita Aloitus-näkymästä" +"Start point" = "Alkupiste" +"Start sharing" = "Aloita jakaminen" +"Start sharing with the Share button, then pass on the code it gives" = "Aloita jakaminen Jaa-painikkeella ja välitä sen antama koodi" +"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" +"Stroke" = "Reunaviiva" +"Stroke style" = "Reunaviivan tyyli" +"Stroke weight" = "Reunaviivan paksuus" +"Strokes" = "Reunaviivat" +"Style" = "Tyyli" +"Styles" = "Tyylit" +"Stylistic set 1" = "Tyylijoukko 1" +"Stylistic set 2" = "Tyylijoukko 2" +"Stylistic set 3" = "Tyylijoukko 3" +"Stylistic set 4" = "Tyylijoukko 4" +"Stylistic set 5" = "Tyylijoukko 5" +"Subtract" = "Vähennä" +"Subtracts" = "Vähentää" +"Suffix" = "Jälkiliite" +"Suspend snapping while dragging" = "Keskeytä tarttuminen vetämisen ajaksi" +"Swap instance" = "Vaihda ilmentymä" +"Swap library: use another library's assets of the same names" = "Vaihda kirjasto: käytä toisen kirjaston samannimisiä resursseja" +"Swap orientation" = "Vaihda suunta" +"Swap overlay" = "Vaihda peittokuva" +"Swap to {name}" = "Vaihda kohteeseen {name}" +"Swap width and height" = "Vaihda leveys ja korkeus keskenään" +"Swapped instance" = "Ilmentymä vaihdettu" +"Swapped {n} assets" = { one = "Vaihdettu {n} resurssi", other = "Vaihdettu {n} resurssia" } +"Swapped {n} assets; {missed} had no match and still use the old library" = { one = "Vaihdettu {n} resurssi; ilman vastinetta jäi {missed}, ja ne käyttävät yhä vanhaa kirjastoa", other = "Vaihdettu {n} resurssia; ilman vastinetta jäi {missed}, ja ne käyttävät yhä vanhaa kirjastoa" } +"Sweep" = "Pyyhkäisy" +"System" = "Järjestelmä" +"Tab / Shift+Tab" = "Tab / Shift+Tab" +"Table" = "Taulukko" +"Tablet" = "Tabletti" +"Tabular numbers" = "Tasalevyiset numerot" +"Take the values from the first selected layer" = "Ota arvot ensimmäisestä valitusta tasosta" +"Taper" = "Kapeneminen" +"Taper both ends" = "Kavenna molemmat päät" +"Tell the server's operator what is wrong with it" = "Kerro palvelimen ylläpitäjälle, mikä siinä on vikana" +"Temperature" = "Lämpötila" +"Template" = "Malli" +"Templates" = "Mallit" +"Terms" = "Ehdot" +"Text" = "Teksti" +"Text align" = "Tekstin tasaus" +"Text content" = "Tekstisisältö" +"Text size" = "Tekstin koko" +"Text style" = "Tekstityyli" +"Text styles" = "Tekstityylit" +"Texts" = "Tekstit" +"Texture" = "Tekstuuri" +"The canvas previews colors in sRGB; exported PNG, JPG and WebP files are tagged Display P3." = "Piirtoalue esikatselee värit sRGB-muodossa. Vietäviin PNG-, JPG- ja WebP-tiedostoihin merkitään Display P3 -profiili." +"The destination document is unavailable. Nothing was imported." = "Kohdeasiakirja ei ole käytettävissä. Mitään ei tuotu." +"The destination document was closed. Nothing was imported." = "Kohdeasiakirja suljettiin. Mitään ei tuotu." +"The destination page was removed. Nothing was imported." = "Kohdesivu poistettiin. Mitään ei tuotu." +"The file is deleted from the server: nobody can open or join it from there again, and this can't be undone. Copies that are open stay with whoever has them." = "Tiedosto poistetaan palvelimelta. Kukaan ei voi enää avata sitä tai liittyä siihen sieltä, eikä tätä voi kumota. Avoimet kopiot säilyvät käyttäjillään." +"The file now holds another library" = "Tiedosto sisältää nyt toisen kirjaston" +"The file now holds another library (\"{name}\")" = "Tiedosto sisältää nyt toisen kirjaston (”{name}”)" +"The files you shared or published online from this computer. They stay on their server until you remove them." = "Tältä tietokoneelta verkossa jakamasi tai julkaisemasi tiedostot. Ne säilyvät palvelimellaan, kunnes poistat ne." +"The link carries the session's secret: share it only with people you invite." = "Linkki sisältää istunnon salaisen tunnisteen. Jaa se vain kutsumillesi henkilöille." +"The name in your online files and on the server. The document inside keeps its own name." = "Nimi verkkotiedostoissasi ja palvelimella. Sisällä oleva asiakirja säilyttää oman nimensä." +"The selection uses several text styles: click to apply one" = "Valinta käyttää useita tekstityylejä. Käytä yhtä niistä napsauttamalla." +"The server deletes this file if nobody opens it before {date}." = "Palvelin poistaa tämän tiedoston, ellei kukaan avaa sitä ennen päivää {date}." +"The whole file" = "Koko tiedosto" +"Theme" = "Teema" +"There's no destination for that action" = "Toiminnolla ei ole kohdetta" +"Thickness" = "Paksuus" +"This branch can't be reviewed: {e}" = "Tätä haaraa ei voi tarkistaa: {e}" +"This branch was merged or archived." = "Tämä haara on yhdistetty tai arkistoitu." +"This computer only" = "Vain tämä tietokone" +"This computer remembers the owner token, so the file can be managed from the home screen's Online list. Copy it to manage the file from somewhere else: it is a password for this file and won't be shown again." = "Tämä tietokone muistaa omistajan tunnisteen, joten tiedostoa voi hallita aloitusnäkymän Verkossa-luettelosta. Kopioi tunniste hallitaksesi tiedostoa muualla. Se on tämän tiedoston salasana, eikä sitä näytetä uudelleen." +"This condition is edited as an expression" = "Tätä ehtoa muokataan lausekkeena" +"This file" = "Tämä tiedosto" +"This file has unsaved changes" = "Tässä tiedostossa on tallentamattomia muutoksia" +"This file is on {server}, open only to you. Share it to let others in with a new code." = "Tämä tiedosto on palvelimella {server} vain sinun käytössäsi. Jaa se, jotta muut voivat liittyä uudella koodilla." +"This file uses fonts that aren't installed on this computer. Their text is drawn in a fallback font until you install them." = "Tämä tiedosto käyttää fontteja, joita ei ole asennettu tälle tietokoneelle. Teksti piirretään korvaavalla fontilla, kunnes asennat puuttuvat fontit." +"This frame is new on the branch." = "Tämä kehys on uusi haarassa." +"This frame isn't on the branch." = "Tätä kehystä ei ole haarassa." +"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" +"Tint" = "Sävytys" +"Title" = "Otsikko" +"Title case" = "Sanojen isot alkukirjaimet" +"To share this library with your team, publish the file to a server (Share ▸ Publish to server…), then publish the library there." = "Jaa tämä kirjasto tiimillesi julkaisemalla tiedosto palvelimelle (Jaa ▸ Julkaise palvelimelle…) ja julkaisemalla kirjasto siellä." +"Toggle" = "Vaihda tila" +"Toggle light/dark theme" = "Vaihda vaalea/tumma teema" +"Toggle visibility" = "Vaihda näkyvyys" +"Tools" = "Työkalut" +"Top" = "Ylhäältä" +"Top & bottom" = "Ylä- ja alareuna" +"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" +"Truncate text" = "Lyhennä tekstiä" +"Truncate with an ellipsis" = "Lyhennä kolmella pisteellä" +"Try again" = "Yritä uudelleen" +"Try another search or filter." = "Kokeile toista hakua tai suodatinta." +"Try another search, or browse templates." = "Kokeile toista hakua tai selaa malleja." +"Try another word, or look at every kind." = "Kokeile toista sanaa tai katso kaikki tyypit." +"Turn a layer into a component, reuse it, and change every copy at once" = "Muunna taso komponentiksi, käytä sitä uudelleen ja muuta kaikkia kopioita kerralla" +"Two digits" = "Kaksi numeroa" +"Type" = "Teksti" +"Type settings" = "Tekstiasetukset" +"Type the join code you were given, or paste an invite link." = "Kirjoita saamasi liittymiskoodi tai liitä kutsulinkki." +"Typography" = "Typografia" +"Underline" = "Alleviivaus" +"Undo takes back only your own changes; what others changed since stays." = "Kumoaminen peruu vain omat muutoksesi. Muiden sen jälkeen tekemät muutokset säilyvät." +"Ungroup" = "Pura ryhmittely" +"Uniform" = "Tasainen" +"Union" = "Yhdiste" +"Unions" = "Yhdistää" +"Unknown — treated as sRGB" = "Tuntematon — käsitellään sRGB:nä" +"Unread" = "Lukematon" +"Unresolve" = "Merkitse ratkaisemattomaksi" +"Unsaved changes" = "Tallentamattomat muutokset" +"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" +"Update" = "Päivitä" +"Update from main" = "Päivitä päähaarasta" +"Update from selection" = "Päivitä valinnasta" +"Update plugin" = "Päivitä liitännäinen" +"Updated {components} and {styles}" = "Päivitetty {components} ja {styles}" +"Updated {name}" = "Päivitetty: {name}" +"Updating the graphics driver often helps. To use OpenGL only, start FigCraft from a Command Prompt with:" = "Näytönohjainajurin päivittäminen auttaa usein. Käytä vain OpenGL:ää käynnistämällä FigCraft komentokehotteesta näin:" +"Updating the graphics driver often helps. WGPU_BACKEND (vulkan, gl, metal) names another graphics API to try." = "Näytönohjainajurin päivittäminen auttaa usein. WGPU_BACKEND (vulkan, gl, metal) määrittää toisen kokeiltavan grafiikkarajapinnan." +"Uploads this file to a FigCraft server and keeps this tab in sync with it. You get a link to edit and a link to view." = "Lataa tämän tiedoston FigCraft-palvelimelle ja pitää tämän välilehden synkronoituna sen kanssa. Saat muokkauslinkin ja katselulinkin." +"Uppercase" = "Isot kirjaimet" +"Use a fixed value" = "Käytä kiinteää arvoa" +"Use an expression, e.g. count + 1" = "Käytä lauseketta, esim. count + 1" +"Use as mask" = "Käytä maskina" +"Use auto layout" = "Käytä automaattista asettelua" +"Use number keys for opacity" = "Säädä peittävyyttä numeronäppäimillä" +"Use the .figcraft or .fig extension." = "Käytä .figcraft- tai .fig-tiedostopäätettä." +"Use the layer's fill" = "Käytä tason täyttöä" +"Used in {files} · {instances}" = "Käytössä tiedostoissa {files} · {instances}" +"Username" = "Käyttäjänimi" +"Uses variables: each layer shows them in its own mode" = "Käyttää muuttujia: kukin taso näyttää ne omassa tilassaan" +"Value" = "Arvo" +"Variable" = "Muuttuja" +"Variable collections" = "Muuttujakokoelmat" +"Variable modes" = "Muuttujatilat" +"Variable width" = "Vaihteleva leveys" +"Variables" = "Muuttujat" +"Variables JSON…" = "Muuttujat JSON…" +"Variables store reusable values (colours, numbers, strings, booleans) with modes such as Light and Dark." = "Muuttujat tallentavat uudelleenkäytettäviä arvoja (värejä, lukuja, merkkijonoja ja totuusarvoja). Niillä voi olla tiloja, kuten vaalea ja tumma." +"Variant" = "Variantti" +"Vector" = "Vektori" +"Vectors" = "Vektorit" +"Version" = "Versio" +"Version history" = "Versiohistoria" +"Version {version}" = "Versio {version}" +"Version {v} · {components} · {styles} · {variables}" = "Versio {v} · {components} · {styles} · {variables}" +"Vertical" = "Pystysuunta" +"Vertical align" = "Pystytasaus" +"Vertical hex" = "Pystysuuntainen kuusikulmio" +"Vertical padding" = "Pystysuuntainen sisämarginaali" +"Vertical spacing" = "Pystyvälistys" +"Vertical trim" = "Pystysuuntainen rajaus" +"View" = "Näytä" +"View link" = "Katselulinkki" +"View link copied" = "Katselulinkki kopioitu" +"View only" = "Vain katselu" +"View only: you see every change as it happens but can't edit. Leave to keep a copy you can edit." = "Vain katselu: näet kaikki muutokset reaaliajassa, mutta et voi muokata. Poistu säilyttääksesi muokattavan kopion." +"Viewing “{name}”" = "Katsellaan tiedostoa ”{name}”" +"Violet" = "Violetti" +"Visibility" = "Näkyvyys" +"Visible" = "Näkyvä" +"Vivid" = "Kirkas" +"WGPU_BACKEND is set and names the graphics API to use; unsetting it lets FigCraft choose." = "WGPU_BACKEND on asetettu ja määrittää käytettävän grafiikkarajapinnan. Poista se käytöstä, jotta FigCraft voi valita." +"Warm" = "Lämmin" +"Watch" = "Seuraa" +"Wavy" = "Aaltoileva" +"Web" = "Verkko" +"WebSocket URL" = "WebSocket-URL" +"WebSocket URL copied" = "WebSocket-URL kopioitu" +"Websites" = "Verkkosivustot" +"Wedge" = "Kiila" +"Weight" = "Paksuus" +"What changed since it was marked ready for dev" = "Muutokset kehitysvalmiiksi merkitsemisen jälkeen" +"What is wrong with this file? The server's operator gets your report." = "Mikä tässä tiedostossa on vikana? Palvelimen ylläpitäjä saa ilmoituksesi." +"What it's for" = "Käyttötarkoitus" +"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." = "Aiempi tila ei ole tiedossa: muutos tehtiin ennen kuin FigCraft tallensi kopioita kehitysvalmiista kehyksistä. Ratkaise muutos tai merkitse kehys uudelleen valmiiksi, jotta jatkossa vertailu onnistuu." +"What you try here changes this preview and the code below, not the file" = "Tässä kokeilemasi asiat muuttavat esikatselua ja alla olevaa koodia, eivät tiedostoa" +"While hovering" = "Osoitettaessa" +"While pressing" = "Painettaessa" +"White" = "Valkoinen" +"Who runs it, its terms and how long it keeps files" = "Ylläpitäjä, käyttöehdot ja tiedostojen säilytysaika" +"Why it changed" = "Muutoksen syy" +"Widget" = "Pienoisohjelma" +"Widgets" = "Pienoisohjelmat" +"Width" = "Leveys" +"Width point" = "Leveyspiste" +"Width profile" = "Leveysprofiili" +"Wireframe kit" = "Rautalankapaketti" +"With the layers' export settings" = "Tasojen vientiasetuksilla" +"Wrap" = "Rivitys" +"Wrap in new section" = "Ympäröi uudella osiolla" +"Yellow" = "Keltainen" +"Yesterday" = "Eilen" +"You" = "Sinä" +"You'll get the shared file as a new tab and edit it together." = "Jaettu tiedosto avautuu uuteen välilehteen, ja voitte muokata sitä yhdessä." +"You're spotlighting: everyone follows your view" = "Olet esillä: kaikki seuraavat näkymääsi" +"Your copy of the file stays open" = "Oma kopiosi tiedostosta pysyy auki" +"Your copy stays open; the file stays on the server" = "Oma kopiosi pysyy auki, ja tiedosto jää palvelimelle" +"Your name" = "Nimesi" +"Your name in comments" = "Nimesi kommenteissa" +"Your system asks for reduced motion: animations stay off." = "Järjestelmäsi pyytää vähennettyä liikettä. Animaatiot pidetään pois käytöstä." +"Z" = "Z" +"Z to A" = "Z:sta A:han" +"Zoom" = "Zoomaus" +"Zoom around the pointer" = "Zoomaa osoittimen ympärillä" +"Zoom device to fill window" = "Zoomaa laite ikkunan kokoiseksi" +"Zoom in" = "Lähennä" +"Zoom out" = "Loitonna" +"Zoom to 100%" = "Zoomaa kokoon 100 %" +"Zoom to 200%" = "Zoomaa kokoon 200 %" +"Zoom to 50%" = "Zoomaa kokoon 50 %" +"Zoom to fit" = "Sovita näkymään" +"Zoom to selection" = "Zoomaa valintaan" +"Zoom tool: click to zoom in, drag an area" = "Zoomaustyökalu: lähennä napsauttamalla tai vedä alue" +"Zoom tool: zoom out" = "Zoomaustyökalu: loitonna" +"and {n} more" = { one = "ja vielä {n}", other = "ja vielä {n}" } +"bleed marks" = "leikkuuvaramerkit" +"by {author}" = "tekijä: {author}" +"call any command its access allows" = "kutsua kaikkia käyttöoikeuksiensa sallimia komentoja" +"call only: {commands}" = "kutsua vain: {commands}" +"can edit the document" = "voi muokata asiakirjaa" +"conditional prototype actions nested too deeply were skipped" = "liian syvälle sisäkkäiset ehdolliset prototyyppitoiminnot ohitettiin" +"conditional prototype actions with conditions that can't be evaluated were skipped" = "ehdolliset prototyyppitoiminnot ohitettiin, koska niiden ehtoja ei voi laskea" +"connector label background was skipped: style data is missing" = "yhdistimen tekstin tausta ohitettiin: tyylitiedot puuttuvat" +"connector label was skipped: background bounds are invalid" = "yhdistimen teksti ohitettiin: taustan rajat ovat virheelliset" +"connector label was skipped: background transform is invalid" = "yhdistimen teksti ohitettiin: taustan muunnos on virheellinen" +"connector label was skipped: composed text transform is invalid" = "yhdistimen teksti ohitettiin: yhdistetty tekstimuunnos on virheellinen" +"connector label was skipped: derived background bounds are missing" = "yhdistimen teksti ohitettiin: johdetut taustan rajat puuttuvat" +"connector label was skipped: derived text bounds are missing" = "yhdistimen teksti ohitettiin: johdetut tekstin rajat puuttuvat" +"connector label was skipped: text bounds are invalid" = "yhdistimen teksti ohitettiin: tekstin rajat ovat virheelliset" +"connector label was skipped: text style data is missing" = "yhdistimen teksti ohitettiin: tekstin tyylitiedot puuttuvat" +"connector label was skipped: text transform is invalid" = "yhdistimen teksti ohitettiin: tekstin muunnos on virheellinen" +"connector labels were skipped" = "yhdistimien tekstit ohitettiin" +"connector layers import as static frame/vector snapshots with labels when present; live endpoint bindings are not retained" = "yhdistintasot tuodaan staattisina kehys- ja vektorikuvina mahdollisine teksteineen; päiden reaaliaikaisia sidoksia ei säilytetä" +"connector snapshot vector data is missing; connector was left empty" = "yhdistimen vektorikuvatiedot puuttuvat; yhdistin jätettiin tyhjäksi" +"connectors whose ends can't be placed are empty" = "yhdistimet, joiden päitä ei voi sijoittaa, ovat tyhjiä" +"crop marks" = "leikkausmerkit" +"damaged vector geometry was skipped" = "vaurioitunut vektorigeometria ohitettiin" +"damaged vector networks import as outlines" = "vaurioituneet vektoriverkot tuodaan ääriviivoina" +"development: {path}" = "kehitys: {path}" +"document children other than pages were skipped" = "asiakirjan muut kuin sivuina olevat alikohteet ohitettiin" +"duplicate grid track sizing entries use equal tracks" = "päällekkäiset ruudukon kokomääritykset käyttävät tasakokoisia osia" +"e.g. count + 1" = "esim. count + 1" +"e.g. count >= 3 and not done" = "esim. count >= 3 and not done" +"elbowed and curved connectors without a saved path import as straight lines" = "mutkaiset ja kaarevat yhdistimet, joilla ei ole tallennettua polkua, tuodaan suorina viivoina" +"entries without a node type were skipped" = "kohteet, joilta puuttuu solmutyyppi, ohitettiin" +"figcraft:// links from a browser, chat or email open this app and join the session." = "selaimen, keskustelun tai sähköpostin figcraft://-linkit avaavat tämän sovelluksen ja liittyvät istuntoon." +"filled areas of a vector with their own fill use the layer's fill" = "vektorin omalla täytöllä määritetyt alueet käyttävät tason täyttöä" +"from {server}" = "palvelimelta {server}" +"gamepad triggers without exactly one known controller button were skipped" = "peliohjainlaukaisimet, joilla ei ole täsmälleen yhtä tunnettua ohjainpainiketta, ohitettiin" +"grid track list exceeds 1000 entries; the tracks use the default equal sizing" = "ruudukon osien luettelo ylittää 1000 kohdetta; osat käyttävät oletuksen mukaista tasakokoa" +"grid track sizing is unsupported or malformed; affected tracks use equal sizing" = "ruudukon kokomääritys on virheellinen tai sitä ei tueta; kyseiset osat käyttävät tasakokoa" +"grid track sizing list exceeds 1000 entries; the tracks use the default equal sizing" = "ruudukon kokomääritysten luettelo ylittää 1000 kohdetta; osat käyttävät oletuksen mukaista tasakokoa" +"hidden" = "kätketty" +"iOS" = "iOS" +"image filters are ambiguous because both filter containers are present; filter conversion was skipped" = "kuvasuotimet ovat monitulkintaisia, koska molemmat suodinsäilöt ovat läsnä; suotimien muunnos ohitettiin" +"image filters use FigCraft's approximate renderer" = "kuvasuotimet käyttävät FigCraftin likimääräistä renderöijää" +"instance swap properties whose default component isn't in the file were skipped" = "ilmentymän vaihto-ominaisuudet ohitettiin, koska niiden oletuskomponenttia ei ole tiedostossa" +"instances inside their own main component were imported as frames" = "oman pääkomponenttinsa sisällä olevat ilmentymät tuotiin kehyksinä" +"instances of components that aren't in the file show no layers" = "tiedostosta puuttuvien komponenttien ilmentymät eivät näytä tasoja" +"instances of components that aren't in the file were imported as frames" = "tiedostosta puuttuvien komponenttien ilmentymät tuotiin kehyksinä" +"instances that would nest a component inside itself were imported as frames" = "ilmentymät, jotka sijoittaisivat komponentin itsensä sisään, tuotiin kehyksinä" +"instances without a main component were imported as empty frames" = "ilmentymät, joilta puuttuu pääkomponentti, tuotiin tyhjinä kehyksinä" +"invalid internal .fig text path data was ignored" = "virheelliset sisäiset .fig-tekstipolun tiedot ohitettiin" +"invalid or oversized vector networks import as outlines" = "virheelliset tai liian suuret vektoriverkot tuodaan ääriviivoina" +"invalid or oversized vector path data was skipped" = "virheelliset tai liian suuret vektoripolun tiedot ohitettiin" +"just now" = "juuri nyt" +"key triggers without a supported key were skipped" = "näppäinlaukaisimet, joilla ei ole tuettua näppäintä, ohitettiin" +"key up triggers import as key presses" = "näppäimen vapautuslaukaisimet tuodaan näppäimen painalluksina" +"layers without a position were placed at their parent's origin" = "tasot, joilta puuttuu sijainti, sijoitettiin emotasonsa origoon" +"layers without a type were skipped" = "tasot, joilta puuttuu tyyppi, ohitettiin" +"legacy image filter container is unsupported; filter conversion was skipped" = "vanhaa kuvasuodinsäilöä ei tueta; suotimien muunnos ohitettiin" +"locked" = "lukittu" +"malformed image filters container was skipped" = "virheellinen image filters -säilö ohitettiin" +"malformed image paintFilter container was skipped" = "virheellinen image paintFilter -säilö ohitettiin" +"malformed slot content assignment was skipped" = "virheellinen sisältöpaikan sisällön määritys ohitettiin" +"no bleed" = "ei leikkuuvaraa" +"nobody else here yet" = "kukaan muu ei ole vielä paikalla" +"non-default slot settings are not represented and were skipped" = "oletuksesta poikkeavia sisältöpaikan asetuksia ei esitetä, ja ne ohitettiin" +"now" = "nyt" +"only in a text style or variable" = "vain tekstityylissä tai muuttujassa" +"or" = "tai" +"point stroke joins were preserved; dashed or variable-width strokes render with the layer join" = "pisteiden viivaliitokset säilytettiin; katko- ja vaihtelevanlevyiset viivat käyttävät tason liitostyyliä" +"preferred slot component keys cannot be resolved to file nodes and were skipped" = "sisältöpaikan ensisijaisten komponenttien avaimia ei voitu yhdistää tiedoston solmuihin, ja ne ohitettiin" +"prototype actions of unknown types were skipped" = "tuntemattoman tyyppiset prototyyppitoiminnot ohitettiin" +"prototype actions without a destination in the file were skipped" = "prototyyppitoiminnot, joiden kohdetta ei ole tiedostossa, ohitettiin" +"prototype expressions on variables that aren't in the variables JSON were skipped" = "muuttujiin viittaavat prototyyppilausekkeet ohitettiin, koska muuttujia ei ole muuttujien JSON-tiedostossa" +"prototype expressions with unsupported functions were skipped" = "prototyyppilausekkeet, joissa on tukemattomia funktioita, ohitettiin" +"prototype interactions without supported actions were skipped" = "prototyypin vuorovaikutukset, joissa ei ole tuettuja toimintoja, ohitettiin" +"prototype navigations of unknown types were skipped" = "tuntemattoman tyyppiset prototyypin siirtymät kohteeseen ohitettiin" +"prototype transitions of unknown types play instantly" = "tuntemattoman tyyppiset prototyypin siirtymäanimaatiot toistuvat välittömästi" +"prototype triggers of unknown types were skipped" = "tuntemattoman tyyppiset prototyypin laukaisimet ohitettiin" +"put a widget on the canvas" = "lisätä pienoisohjelman piirtoalueelle" +"read and edit the document" = "lukea ja muokata asiakirjaa" +"read the document" = "lukea asiakirjan" +"reads the document" = "lukee asiakirjan" +"set-variable actions on variables that aren't in the variables JSON were skipped" = "muuttujan asetustoiminnot ohitettiin, koska muuttujia ei ole muuttujien JSON-tiedostossa" +"set-variable actions with values that can't be evaluated were skipped" = "muuttujan asetustoiminnot ohitettiin, koska arvoja ei voi laskea" +"set-variable actions with values that don't fit the variable were skipped" = "muuttujan asetustoiminnot ohitettiin, koska arvot eivät sovi muuttujaan" +"set-variable actions without a value were skipped" = "muuttujan asetustoiminnot, joilta puuttuu arvo, ohitettiin" +"set-variable-mode actions on collections or modes that aren't in the variables JSON were skipped" = "muuttujatilan asetustoiminnot ohitettiin, koska kokoelmia tai tiloja ei ole muuttujien JSON-tiedostossa" +"slot content assignment target is missing or is not slot content" = "sisältöpaikan määrityksen kohde puuttuu tai ei ole sisältöpaikan sisältöä" +"slot content assignment uses the empty GUID sentinel and was skipped" = "sisältöpaikan määritys käyttää tyhjää GUID-erikoisarvoa, ja se ohitettiin" +"slot layers without a matching slot property were imported as frames" = "sisältöpaikkatasot, joilla ei ole vastaavaa sisältöpaikkaominaisuutta, tuotiin kehyksinä" +"some vector points refer to point settings missing from the file; they use the layer's joins and plain corners" = "jotkin vektoripisteet viittaavat tiedostosta puuttuviin pisteasetuksiin; ne käyttävät tason liitostyyliä ja tavallisia kulmia" +"some vector segments refer to style settings missing from the file; they use the layer's stroke" = "jotkin vektorisegmentit viittaavat tiedostosta puuttuviin tyyliasetuksiin; ne käyttävät tason reunaviivaa" +"stale or invalid grid track sizing entries were ignored" = "vanhentuneet tai virheelliset ruudukon kokomääritykset ohitettiin" +"stars and polygons without point counts or geometry use the default point count" = "tähdet ja monikulmiot, joilta puuttuu pistemäärä tai geometria, käyttävät oletuspistemäärää" +"style settings of some vector segments aren't supported; those segments use the layer's stroke" = "joidenkin vektorisegmenttien tyyliasetuksia ei tueta; nämä segmentit käyttävät tason reunaviivaa" +"text path underline or strikethrough decoration is not rendered" = "tekstipolun alleviivausta tai yliviivausta ei renderöidä" +"text style overrides refer to missing table entries" = "tekstityylin ohitukset viittaavat puuttuviin taulukkokohteisiin" +"the file has no pages" = "tiedostossa ei ole sivuja" +"the plugin" = "liitännäinen" +"the variables JSON has no `variableCollections` (expected the local variables response) and was ignored" = "muuttujien JSON-tiedostosta puuttuu `variableCollections` (odotettiin paikallisten muuttujien vastausta), ja tiedosto ohitettiin" +"this session only" = "vain tämä istunto" +"unknown component property types were skipped" = "tuntemattoman tyyppiset komponenttiominaisuudet ohitettiin" +"variable aliases to variables that aren't in the variables JSON (or of another type) were skipped" = "muuttuja-aliakset ohitettiin, koska kohdemuuttujia ei ole muuttujien JSON-tiedostossa tai ne ovat eri tyyppiä" +"variable bindings to properties the layer doesn't have were skipped" = "muuttujasidokset ominaisuuksiin, joita tasolla ei ole, ohitettiin" +"variable bindings to variables that aren't in the variables JSON (library variables) were skipped" = "muuttujasidokset muuttujiin, joita ei ole muuttujien JSON-tiedostossa (kirjastomuuttujat), ohitettiin" +"variable bindings were skipped: the file JSON has no variable definitions (pass the local variables JSON as `variables`, or embed its `meta` next to the file JSON)" = "muuttujasidokset ohitettiin: tiedoston JSON ei määritä muuttujia (anna paikallisten muuttujien JSON arvona `variables` tai sisällytä sen `meta` tiedoston JSONin viereen)" +"variable bindings whose variable type doesn't fit the property were skipped" = "muuttujasidokset, joiden muuttujatyyppi ei sovi ominaisuuteen, ohitettiin" +"variable collections without modes were skipped" = "muuttujakokoelmat, joilla ei ole tiloja, ohitettiin" +"variable mode lookups in prototype expressions that aren't a variable and one of its modes were skipped" = "prototyyppilausekkeiden muuttujatilahaut, jotka eivät ole muuttuja ja jokin sen tiloista, ohitettiin" +"variable modes of collections that aren't in the file were skipped" = "tiedostosta puuttuvien kokoelmien muuttujatilat ohitettiin" +"variable modes of collections that aren't in the variables JSON were skipped" = "muuttujien JSON-tiedostosta puuttuvien kokoelmien muuttujatilat ohitettiin" +"variable values that don't match their type (or composed colours) were skipped" = "muuttujien arvot, jotka eivät vastaa tyyppiään (tai yhdistettyjä värejä), ohitettiin" +"variables of unknown types were skipped" = "tuntemattoman tyyppiset muuttujat ohitettiin" +"variables whose collection isn't in the variables JSON were skipped" = "muuttujat, joiden kokoelmaa ei ole muuttujien JSON-tiedostossa, ohitettiin" +"variables without readable values were skipped" = "muuttujat, joiden arvoja ei voi lukea, ohitettiin" +"vector points with unknown stroke joins use the layer join" = "vektoripisteet, joilla on tuntematon liitostyyli, käyttävät tason liitostyyliä" +"vectors without geometry are empty" = "vektorit ilman geometriaa ovat tyhjiä" +"vectors without geometry are empty (export the file JSON with geometry=paths)" = "vektorit ilman geometriaa ovat tyhjiä (vie tiedoston JSON asetuksella geometry=paths)" +"very long text was cut to 1,048,576 characters" = "erittäin pitkä teksti katkaistiin 1 048 576 merkkiin" +"very long vector paths were skipped" = "erittäin pitkät vektoripolut ohitettiin" +"video actions are not supported and were skipped" = "videotoimintoja ei tueta, ja ne ohitettiin" +"video triggers are not supported and were skipped" = "videolaukaisimia ei tueta, ja ne ohitettiin" +"yesterday" = "eilen" +"{instances} in {files}" = "{instances} tiedostoissa {files}" +"{language}: draft translation, waiting for a native speaker's review." = "{language}: käännösluonnos, joka odottaa äidinkielisen puhujan tarkistusta." +"{length} bleed" = "Leikkuuvara {length}" +"{month} {day}, {year}" = "{day}. {month} {year}" +"{name}\nClick to apply to the selection" = "{name}\nKäytä valintaan napsauttamalla" +"{name}\nDrag onto the canvas, or click to place in the middle of the view.\nHold Alt (Option) while dropping onto an instance to swap it." = "{name}\nVedä piirtoalueelle tai sijoita näkymän keskelle napsauttamalla.\nVaihda ilmentymä pudottamalla sen päälle Alt (Option) painettuna." +"{name} (from {library})" = "{name} (kirjastosta {library})" +"{name} (you)" = "{name} (sinä)" +"{name} ({layers} layers)" = "{name} (tasoja: {layers})" +"{name} ({tag}): {min} – {max}, default {default}. Double-click the slider to go back to the style's value; Escape while dragging cancels." = "{name} ({tag}): {min} – {max}, oletus {default}. Palauta tyylin arvo kaksoisnapsauttamalla liukusäädintä. Escape peruuttaa vetämisen." +"{name} cleared from the list" = "{name} poistettu luettelosta" +"{name} from {path}" = "{name} polusta {path}" +"{name} is forgotten on this computer; the file stays on {server}" = "{name} on unohdettu tällä tietokoneella. Tiedosto jää palvelimelle {server}." +"{name} is on the server." = "{name} on palvelimella." +"{name} is spotlighting" = "{name} nostaa näkymäänsä esiin" +"{name} is too large to open in the browser (over 512 MB)." = "{name} on liian suuri avattavaksi selaimessa (yli 512 Mt)." +"{name} was removed from the server" = "{name} poistettiin palvelimelta" +"{name} · deleted on main" = "{name} · poistettu päähaarassa" +"{name} · deleted on the branch" = "{name} · poistettu haarassa" +"{name} · not published" = "{name} · ei julkaistu" +"{name} · on a server" = "{name} · palvelimella" +"{name} · published version {v}" = "{name} · julkaistu versio {v}" +"{name} · version {v} on the server" = "{name} · versio {v} palvelimella" +"{name}, in a live session" = "{name}, reaaliaikaisessa istunnossa" +"{name}, online, open only to you" = "{name}, verkossa, vain sinun käytössäsi" +"{name}, {kind}, {w} by {h}, selected" = "{name}, {kind}, {w} × {h}, valittu" +"{n} added" = { one = "Lisätty: {n}", other = "Lisätty: {n}" } +"{n} autosave versions" = { one = "{n} automaattisesti tallennettu versio", other = "{n} automaattisesti tallennettua versiota" } +"{n} changed" = { one = "Muutettu: {n}", other = "Muutettu: {n}" } +"{n} columns" = { one = "{n} sarake", other = "{n} saraketta" } +"{n} components" = { one = "{n} komponentti", other = "{n} komponenttia" } +"{n} components and styles have newer versions" = { one = "Uudempi versio saatavilla: {n} komponentti tai tyyli", other = "Uudempia versioita saatavilla: {n} komponenttia ja tyyliä" } +"{n} conflicts to resolve" = { one = "{n} ratkaistava ristiriita", other = "{n} ratkaistavaa ristiriitaa" } +"{n} days ago" = { one = "{n} päivä sitten", other = "{n} päivää sitten" } +"{n} files" = { one = "{n} tiedosto", other = "{n} tiedostoa" } +"{n} hours ago" = { one = "{n} tunti sitten", other = "{n} tuntia sitten" } +"{n} instances" = { one = "{n} ilmentymä", other = "{n} ilmentymää" } +"{n} layers" = { one = "{n} taso", other = "{n} tasoa" } +"{n} layers as one file" = { one = "{n} taso yhtenä tiedostona", other = "{n} tasoa yhtenä tiedostona" } +"{n} layers selected" = { one = "{n} taso valittu", other = "{n} tasoa valittu" } +"{n} layers · {parts}" = { one = "{n} taso · {parts}", other = "{n} tasoa · {parts}" } +"{n} matches" = { one = "{n} osuma", other = "{n} osumaa" } +"{n} minutes ago" = { one = "{n} minuutti sitten", other = "{n} minuuttia sitten" } +"{n} months ago" = { one = "{n} kuukausi sitten", other = "{n} kuukautta sitten" } +"{n} more notes omitted from this report" = { one = "Tästä raportista on jätetty pois vielä {n} huomautus", other = "Tästä raportista on jätetty pois vielä {n} huomautusta" } +"{n} of {total}" = "{n} / {total}" +"{n} people joined" = { one = "{n} henkilö liittyi", other = "{n} henkilöä liittyi" } +"{n} preferred components" = { one = "{n} ensisijainen komponentti", other = "{n} ensisijaista komponenttia" } +"{n} ready" = { one = "{n} valmis", other = "{n} valmista" } +"{n} removed" = { one = "Poistettu: {n}", other = "Poistettu: {n}" } +"{n} replies" = { one = "{n} vastaus", other = "{n} vastausta" } +"{n} rows" = { one = "{n} rivi", other = "{n} riviä" } +"{n} styles" = { one = "{n} tyyli", other = "{n} tyyliä" } +"{n} text layers" = { one = "{n} tekstitaso", other = "{n} tekstitasoa" } +"{n} variables" = { one = "{n} muuttuja", other = "{n} muuttujaa" } +"{n} weeks ago" = { one = "{n} viikko sitten", other = "{n} viikkoa sitten" } +"{n} years ago" = { one = "{n} vuosi sitten", other = "{n} vuotta sitten" } +"{n}d" = "{n}pv" +"{n}h" = "{n}t" +"{n}m" = "{n}min" +"{n}w" = "{n}vk" +"{page}, nothing selected" = "{page}, ei valintaa" +"{profile} (current)" = "{profile} (nykyinen)" +"{property} is its value; {property ? \"a\" : \"b\"} picks a string. Other {…} stays as written." = "{property} on sen arvo. {property ? \"a\" : \"b\"} valitsee merkkijonon. Muut {…}-ilmaukset säilyvät sellaisinaan." +"{server} can't say what became of your files (it is older than this app)." = "{server} ei pysty kertomaan tiedostojesi tilaa (se on tätä sovellusta vanhempi)." +"{server} · No longer on the server" = "{server} · Ei enää palvelimella" +"{server} · {shared} · Opened {date}" = "{server} · {shared} · Avattu {date}" +"{tool} hold {key}" = "{tool} pidä {key} painettuna" +"{value} isn't a colour" = "{value} ei ole väri" +"{when} · edited" = "{when} · muokattu" +"←" = "←" +"← ↑ → ↓" = "← ↑ → ↓" +"← → ↑ ↓" = "← → ↑ ↓" +"→, Space or N" = "→, Space or N" +"↵" = "↵" +"⇥ / ⇧⇥" = "⇥ / ⇧⇥" +"⌘ arrows" = "⌘ arrows" +"⌘ click" = "⌘ click" +"⌘ scroll" = "⌘ scroll" +"⌥ drag" = "⌥ drag" + +[commands] +"align.bottom" = "Tasaa alas" +"align.hcenter" = "Keskitä vaakasuunnassa" +"align.left" = "Tasaa vasemmalle" +"align.right" = "Tasaa oikealle" +"align.top" = "Tasaa ylös" +"align.vcenter" = "Keskitä pystysuunnassa" +"annotation.add" = "Lisää huomautus" +"annotation.addCategory" = "Lisää huomautusluokka" +"annotation.categories" = "Huomautusten luokat" +"annotation.edit" = "Muokkaa huomautusta" +"annotation.editCategory" = "Muokkaa huomautusluokkaa" +"annotation.list" = "Luettele huomautukset" +"annotation.remove" = "Poista huomautus" +"annotation.removeCategory" = "Poista huomautusluokka" +"app.actions" = "Toiminnot" +"app.preferences" = "Asetukset" +"arrange.back" = "Vie takimmaiseksi" +"arrange.backward" = "Vie taaksepäin" +"arrange.forward" = "Tuo eteenpäin" +"arrange.front" = "Tuo etummaiseksi" +"arrange.packH" = "Pakkaa vaakasuunnassa" +"arrange.packV" = "Pakkaa pystysuunnassa" +"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" +"bool.subtract" = "Vähennä valinta" +"bool.union" = "Yhdistä valinta" +"branch.archive" = "Arkistoi haara" +"branch.create" = "Luo haara" +"branch.list" = "Luettele haarat" +"branch.merge" = "Yhdistä haara" +"branch.open" = "Avaa haara" +"branch.review" = "Tarkista haara" +"branch.updateFromMain" = "Päivitä päähaarasta" +"codeConnect.export" = "Vie koodiyhteydet" +"codeConnect.import" = "Tuo koodiyhteydet" +"codeConnect.remove" = "Katkaise yhteys koodiin" +"codeConnect.set" = "Yhdistä koodiin" +"collab.cursor" = "Näytä kohdistin kohdassa" +"collab.cursorChat" = "Kohdistinkeskustelu" +"collab.diff" = "Näytä synkronoinnin muutokset" +"collab.files" = "Omat verkkotiedostot" +"collab.follow" = "Seuraa" +"collab.forgetFile" = "Unohda verkkotiedosto" +"collab.host" = "Aloita reaaliaikainen istunto" +"collab.join" = "Liity reaaliaikaiseen istuntoon" +"collab.leave" = "Poistu reaaliaikaisesta istunnosta" +"collab.manageFile" = "Hallitse julkaistua tiedostoa" +"collab.openFile" = "Avaa verkkotiedosto" +"collab.peers" = "Tämän tiedoston käyttäjät" +"collab.publish" = "Julkaise palvelimelle" +"collab.recoverFile" = "Palauta verkkotiedosto" +"collab.registerLinks" = "Avaa kutsulinkit FigCraftissa" +"collab.removePerson" = "Poista istunnosta" +"collab.report" = "Ilmoita tiedostosta palvelimen ylläpitäjälle" +"collab.serverInfo" = "Palvelimen tiedot" +"collab.share" = "Jaa liittymiskoodilla" +"collab.spotlight" = "Nosta minut esiin" +"collab.stop" = "Lopeta reaaliaikainen istunto" +"command.batch" = "Suorita useita komentoja" +"comment.add" = "Lisää kommentti" +"comment.delete" = "Poista kommentti" +"comment.edit" = "Muokkaa kommenttia" +"comment.link" = "Kopioi linkki kommenttiin" +"comment.list" = "Luettele kommentit" +"comment.markRead" = "Merkitse kommentti luetuksi" +"comment.move" = "Siirrä kommentti" +"comment.next" = "Seuraava kommentti" +"comment.open" = "Avaa kommentti" +"comment.people" = "Kommenttien osallistujat" +"comment.react" = "Reagoi kommenttiin" +"comment.reply" = "Vastaa kommenttiin" +"comment.resolve" = "Ratkaise kommentti" +"comment.zoom" = "Zoomaa kommentteihin" +"component.addProperty" = "Lisää komponentin ominaisuus" +"component.addVariant" = "Lisää variantti" +"component.bindProperty" = "Sido taso ominaisuuteen" +"component.combineVariants" = "Yhdistä varianteiksi" +"component.create" = "Luo komponentti" +"component.createMultiple" = "Luo useita komponentteja" +"component.deleteProperty" = "Poista komponentin ominaisuus" +"component.detach" = "Irrota ilmentymä" +"component.editProperty" = "Muokkaa komponentin ominaisuutta" +"component.exposeInstance" = "Tuo sisäkkäinen ilmentymä käyttöön" +"component.goToMain" = "Siirry pääkomponenttiin" +"component.hideWhenPublishing" = "Kätke julkaistaessa" +"component.list" = "Luettele komponentit" +"component.makeSlot" = "Luo sisältöpaikka" +"component.playground" = "Komponenttien kokeilualue" +"component.properties" = "Komponentin ominaisuudet" +"component.pushChanges" = "Siirrä muutokset pääkomponenttiin" +"component.removeSlot" = "Poista sisältöpaikka" +"component.resetInstance" = "Palauta kaikki muutokset" +"component.resetOverride" = "Palauta ohitus" +"component.restoreMain" = "Palauta pääkomponentti" +"dev.compare" = "Vertaa muutoksia" +"dev.focus" = "Kohdistusnäkymä" +"dev.resolveChange" = "Ratkaise muutos" +"dev.unfocus" = "Poistu kohdistusnäkymästä" +"devResource.add" = "Lisää kehittäjäresurssi" +"devResource.edit" = "Muokkaa kehittäjäresurssia" +"devResource.list" = "Luettele kehittäjäresurssit" +"devResource.remove" = "Poista kehittäjäresurssi" +"distribute.horizontal" = "Jaa vaakavälit tasan" +"distribute.vertical" = "Jaa pystyvälit tasan" +"doc.home" = "Aloitus" +"doc.list" = "Luettele asiakirjat" +"doc.nextTab" = "Seuraava välilehti" +"doc.previousTab" = "Edellinen välilehti" +"doc.switch" = "Vaihda asiakirjaa" +"edit.copy" = "Kopioi" +"edit.copyAsCss" = "Kopioi CSS-muodossa" +"edit.copyAsPng" = "Kopioi PNG-muodossa" +"edit.copyAsSvg" = "Kopioi SVG-muodossa" +"edit.copyAsText" = "Kopioi tekstinä" +"edit.copyLink" = "Kopioi linkki valintaan" +"edit.copyProperties" = "Kopioi ominaisuudet" +"edit.cut" = "Leikkaa" +"edit.delete" = "Poista" +"edit.duplicate" = "Monista" +"edit.findReplace" = "Etsi ja korvaa" +"edit.paste" = "Liitä" +"edit.pasteDesignLayers" = "Liitä suunnittelutasoja" +"edit.pasteOver" = "Liitä valinnan päälle" +"edit.pasteProperties" = "Liitä ominaisuudet" +"edit.pasteReplace" = "Liitä korvaten" +"edit.pickColor" = "Poimi väri" +"edit.redo" = "Tee uudelleen" +"edit.repeat" = "Toista viimeisin komento" +"edit.setDefaultProperties" = "Aseta oletusominaisuudet" +"edit.undo" = "Kumoa" +"effect.add" = "Lisää tehoste" +"effect.move" = "Siirrä tehoste" +"effect.remove" = "Poista tehoste" +"effect.set" = "Aseta tehoste" +"export.add" = "Lisää vientiasetus" +"export.remove" = "Poista vientiasetus" +"export.update" = "Muokkaa vientiasetusta" +"file.close" = "Sulje välilehti" +"file.colorProfile" = "Väriprofiili" +"file.duplicate" = "Monista tiedosto" +"file.export" = "Vie" +"file.exportFramesPdf" = "Vie kehykset PDF-muodossa" +"file.exportLayers" = "Vie tasot" +"file.exportSvg" = "Vie valinta SVG-muodossa" +"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" +"file.placeImage" = "Sijoita kuva" +"file.recover" = "Palauta tiedosto" +"file.rename" = "Nimeä tiedosto uudelleen" +"file.revert" = "Palauta tallennettu" +"file.save" = "Tallenna" +"file.saveAs" = "Tallenna nimellä" +"file.saveVersion" = "Tallenna versiohistoriaan" +"file.versionHistory" = "Näytä versiohistoria" +"fill.add" = "Lisää täyttö" +"fill.move" = "Siirrä täyttö" +"fill.pattern" = "Kuviotäyttö" +"fill.remove" = "Poista täyttö" +"fill.set" = "Aseta täyttö" +"frame.create" = "Luo kehys" +"frame.presets" = "Luettele kehysten esiasetukset" +"frame.swapOrientation" = "Vaihda suunta" +"grid.add" = "Lisää asetteluruudukko" +"grid.move" = "Siirrä asetteluruudukko" +"grid.remove" = "Poista asetteluruudukko" +"guide.add" = "Lisää apuviiva" +"guide.clear" = "Poista kaikki apuviivat" +"guide.move" = "Siirrä apuviiva" +"guide.remove" = "Poista apuviiva" +"guide.select" = "Valitse apuviiva" +"help.about" = "Tietoja FigCraftista" +"help.gettingStarted" = "Aloittaminen" +"help.page" = "Ohjesivu" +"help.shortcuts" = "Pikanäppäimet" +"image.adjust" = "Säädä kuvaa" +"image.deletePreset" = "Poista kuvan esiasetus" +"image.place" = "Sijoita kuva" +"image.presets" = "Kuvan esiasetukset" +"image.savePreset" = "Tallenna kuvan esiasetus" +"inspect.assets" = "Tarkastele resursseja" +"inspect.code" = "Tason koodi" +"inspect.componentCode" = "Komponentin koodi" +"inspect.properties" = "Tarkastele ominaisuuksia" +"inspect.readyForDev" = "Kehitysvalmis" +"instance.create" = "Luo ilmentymä" +"instance.exposed" = "Käyttöön tuodut sisäkkäiset ilmentymät" +"instance.place" = "Sijoita ilmentymä" +"instance.resetSlot" = "Palauta sisältöpaikka" +"instance.setProperties" = "Aseta ilmentymän ominaisuudet" +"instance.slotInsert" = "Lisää sisältöpaikkaan" +"instance.swap" = "Vaihda ilmentymä" +"instance.swapAt" = "Vaihda ilmentymä pisteessä" +"layers.collapseAll" = "Supista tasot" +"layout.add" = "Lisää automaattinen asettelu" +"layout.remove" = "Poista automaattinen asettelu" +"layout.setTrack" = "Aseta ruudukon osan koko" +"layout.suggest" = "Ehdota automaattista asettelua" +"libraries.open" = "Kirjastot" +"libraries.publish" = "Julkaise kirjasto…" +"library.acceptUpdates" = "Hyväksy kirjaston päivitykset" +"library.add" = "Lisää kirjasto" +"library.aliasVariable" = "Luo alias kirjastomuuttujaan" +"library.applyStyle" = "Käytä kirjastotyyliä" +"library.bindVariable" = "Sido kirjastomuuttuja" +"library.extendCollection" = "Laajenna kirjastokokoelmaa" +"library.import" = "Tuo kirjastokomponentti" +"library.importVariable" = "Tuo kirjastomuuttuja" +"library.list" = "Luettele kirjastot" +"library.place" = "Sijoita kirjastokomponentti" +"library.publish" = "Julkaise kirjasto" +"library.publishToServer" = "Julkaise kirjasto palvelimelle" +"library.remove" = "Poista kirjasto" +"library.setVariableMode" = "Aseta kirjastomuuttujan tila" +"library.swap" = "Vaihda kirjasto" +"library.swapAt" = "Vaihda ilmentymä kirjastokomponenttiin" +"library.updates" = "Kirjaston päivitykset" +"library.usage" = "Kirjaston käyttö" +"measure.distances" = "Mittaa etäisyyksiä" +"measure.padding" = "Mittaa automaattisen asettelun sisämarginaali" +"node.create" = "Luo taso" +"node.get" = "Hae taso" +"node.reorder" = "Muuta tason järjestystä" +"node.reparent" = "Siirrä taso kohteeseen" +"node.set" = "Aseta ominaisuudet" +"object.batchRename" = "Nimeä joukko uudelleen" +"object.convertToFrame" = "Muunna kehykseksi" +"object.convertToGroup" = "Muunna ryhmäksi" +"object.convertToSection" = "Muunna osioksi" +"object.flipH" = "Peilaa vaakasuunnassa" +"object.flipV" = "Peilaa pystysuunnassa" +"object.frameSelection" = "Kehystä valinta" +"object.group" = "Ryhmittele valinta" +"object.hideOthers" = "Kätke muut tasot" +"object.mask" = "Käytä maskina" +"object.moveToPage" = "Siirrä sivulle" +"object.opacityKeys" = "Peittävyys numeronäppäimillä" +"object.rasterize" = "Rasteroi valinta" +"object.releaseMask" = "Vapauta maski" +"object.removeFill" = "Poista täyttö" +"object.removeFrame" = "Poista kehys" +"object.removeStroke" = "Poista reunaviiva" +"object.rename" = "Nimeä uudelleen" +"object.resizeToFit" = "Sovita koko" +"object.rotate180" = "Kierrä 180°" +"object.rotateLeft" = "Kierrä 90° vasemmalle" +"object.rotateRight" = "Kierrä 90° oikealle" +"object.setThumbnail" = "Aseta pikkukuvaksi" +"object.swapFillStroke" = "Vaihda täyttö ja reunaviiva keskenään" +"object.toggleLock" = "Lukitse/avaa valinnan lukitus" +"object.toggleVisible" = "Näytä/kätke valinta" +"object.ungroup" = "Pura valinnan ryhmittely" +"object.wrapInSection" = "Ympäröi uudella osiolla" +"page.add" = "Lisää sivu" +"page.delete" = "Poista sivu" +"page.duplicate" = "Monista sivu" +"page.list" = "Luettele sivut" +"page.move" = "Siirrä sivu" +"page.rename" = "Nimeä sivu uudelleen" +"page.select" = "Siirry sivulle" +"page.setBackground" = "Sivun tausta" +"paint.documentColors" = "Asiakirjan värit" +"plugin.close" = "Sulje liitännäispaneeli" +"plugin.directory.install" = "Asenna liitännäinen hakemistosta" +"plugin.directory.list" = "Selaa liitännäishakemistoa" +"plugin.directory.publish" = "Julkaise liitännäinen hakemistoon" +"plugin.directory.unpublish" = "Peru liitännäisen julkaisu hakemistosta" +"plugin.event" = "Käytä liitännäispaneelin säädintä" +"plugin.import" = "Tuo liitännäinen manifestista…" +"plugin.list" = "Luettele liitännäiset" +"plugin.remove" = "Poista liitännäinen" +"plugin.run" = "Suorita liitännäinen" +"plugin.ui" = "Liitännäispaneeli" +"plugins.manage" = "Hallitse liitännäisiä…" +"plugins.runLast" = "Suorita viimeisin liitännäinen" +"pref.autoSave" = "Tallenna tiedostot automaattisesti" +"pref.highlightHover" = "Korosta tasot osoitettaessa" +"pref.keepTool" = "Pidä työkalu valittuna käytön jälkeen" +"pref.language" = "Käyttöliittymän kieli" +"pref.nudge" = "Siirtoaskeleen koko" +"pref.numberKeysOpacity" = "Säädä peittävyyttä numeronäppäimillä" +"pref.publishServer" = "Verkkojakamisen palvelin" +"pref.reduceMotion" = "Vähennä liikettä" +"pref.renameDuplicates" = "Nimeä monistetut tasot uudelleen" +"pref.showDimensions" = "Näytä objektien mitat" +"pref.startOnHome" = "Aloita Aloitus-näkymästä" +"pref.userName" = "Nimesi kommenteissa" +"proto.action.add" = "Lisää prototyyppitoiminto" +"proto.action.move" = "Muuta prototyyppitoimintojen järjestystä" +"proto.action.remove" = "Poista prototyyppitoiminto" +"proto.action.set" = "Muokkaa prototyyppitoimintoa" +"proto.closePreview" = "Sulje prototyypin esikatselu" +"proto.conditional.addBlock" = "Lisää muuten jos / muuten" +"proto.conditional.removeBlock" = "Poista jos / muuten jos / muuten" +"proto.conditional.setCondition" = "Muokkaa ehtoa" +"proto.evaluate" = "Laske prototyyppilauseke" +"proto.flow.add" = "Lisää kulun aloituspiste" +"proto.flow.remove" = "Poista kulku" +"proto.flow.rename" = "Nimeä kulku uudelleen" +"proto.interaction.add" = "Lisää vuorovaikutus" +"proto.interaction.clear" = "Poista kaikki vuorovaikutukset" +"proto.interaction.remove" = "Poista vuorovaikutus" +"proto.interaction.set" = "Muokkaa vuorovaikutusta" +"proto.present" = "Esitä" +"proto.presentOptions" = "Esityksen asetukset" +"proto.preview" = "Avaa prototyypin esikatselu" +"proto.settings" = "Prototyypin asetukset" +"section.toggleContents" = "Kätke/näytä osion sisältö" +"select.add" = "Lisää valintaan" +"select.all" = "Valitse kaikki" +"select.area" = "Valitse alue" +"select.byName" = "Valitse tasoja nimen perusteella" +"select.children" = "Valitse alikohteet" +"select.inverse" = "Käännä valinta" +"select.layersAt" = "Pisteessä olevat tasot" +"select.matching" = "Valitse vastaavat tasot" +"select.next" = "Valitse seuraava rinnakkaiskohde" +"select.none" = "Poista valinta" +"select.parent" = "Valitse emotaso" +"select.previous" = "Valitse edellinen rinnakkaiskohde" +"select.remove" = "Poista valinnasta" +"select.sameEffect" = "Valitse sama tehoste" +"select.sameFill" = "Valitse sama täyttö" +"select.sameFont" = "Valitse sama fontti" +"select.sameInstance" = "Valitse sama ilmentymä" +"select.sameProperties" = "Valitse samat ominaisuudet" +"select.sameStroke" = "Valitse sama reunaviiva" +"select.sameText" = "Valitse samat tekstiominaisuudet" +"select.set" = "Valitse" +"selection.applyColorStyle" = "Käytä valinnan värityyliä" +"selection.colors" = "Valinnan värit" +"selection.selectColor" = "Valitse tämänväriset tasot" +"selection.setPaint" = "Muuta valinnan väriä" +"shape.arrow" = "Nuoli" +"shape.ellipse" = "Ellipsi" +"shape.line" = "Viiva" +"shape.polygon" = "Monikulmio" +"shape.rectangle" = "Suorakulmio" +"shape.star" = "Tähti" +"shapeBuilder.merge" = "Yhdistä alueet" +"shapeBuilder.mode" = "Muodonrakentimen tila" +"shapeBuilder.regions" = "Muodonrakentimen alueet" +"shapeBuilder.remove" = "Poista alueet" +"stroke.add" = "Lisää reunaviiva" +"stroke.brush" = "Sivellin" +"stroke.move" = "Siirrä reunaviiva" +"stroke.remove" = "Poista reunaviiva" +"stroke.set" = "Aseta reunaviiva" +"stroke.widthProfile" = "Leveysprofiili" +"style.apply" = "Käytä tyyliä" +"style.bind" = "Sido muuttuja tyylissä" +"style.create" = "Luo tyyli" +"style.delete" = "Poista tyyli" +"style.detach" = "Irrota tyyli" +"style.duplicate" = "Monista tyyli" +"style.list" = "Luettele tyylit" +"style.move" = "Siirrä tyylejä" +"style.renameFolder" = "Nimeä tyylikansio uudelleen" +"style.update" = "Muokkaa tyyliä" +"template.list" = "Luettele mallit" +"text.alignCenter" = "Keskitä" +"text.alignJustify" = "Tasaa molemmat reunat" +"text.alignLeft" = "Tasaa vasemmalle" +"text.alignRight" = "Tasaa oikealle" +"text.bold" = "Lihavointi" +"text.bulletList" = "Luettelomerkitty luettelo" +"text.case" = "Kirjainkoko" +"text.caseLower" = "Pienet kirjaimet" +"text.caseOriginal" = "Alkuperäinen kirjainkoko" +"text.caseSmallCaps" = "Kapiteelit" +"text.caseTitle" = "Sanojen isot alkukirjaimet" +"text.caseUpper" = "Isot kirjaimet" +"text.create" = "Teksti" +"text.decoration" = "Koristelu" +"text.edit.begin" = "Muokkaa tekstiä" +"text.edit.caret" = "Aseta kohdistin" +"text.edit.end" = "Lopeta tekstin muokkaus" +"text.edit.insert" = "Kirjoita tekstiä" +"text.edit.key" = "Tekstinäppäin" +"text.edit.preedit" = "Koosta tekstiä (IME)" +"text.edit.selectLine" = "Valitse rivi" +"text.edit.selectWord" = "Valitse sana" +"text.edit.selection" = "Tekstivalinta" +"text.endPreview" = "Lopeta tekstin esikatselu" +"text.fonts" = "Luettele fontit" +"text.italic" = "Kursivointi" +"text.lineHeightDown" = "Pienennä rivikorkeutta" +"text.lineHeightUp" = "Suurenna rivikorkeutta" +"text.link" = "Luo linkki" +"text.listIndent" = "Sisennä luettelo" +"text.listOutdent" = "Vähennä luettelon sisennystä" +"text.listStyle" = "Luettelotyyli" +"text.loadFont" = "Lataa fonttitiedosto" +"text.maxLines" = "Rivien enimmäismäärä" +"text.missingFonts" = "Puuttuvat fontit" +"text.numberList" = "Numeroitu luettelo" +"text.onPath" = "Teksti polulla" +"text.paragraphIndent" = "Kappaleen sisennys" +"text.paragraphSpacing" = "Kappaleväli" +"text.pathOptions" = "Tekstipolun asetukset" +"text.preview" = "Esikatsele tekstimuutosta" +"text.refit" = "Sovita teksti fontteihin" +"text.removeFromPath" = "Poista teksti polulta" +"text.rescanFonts" = "Etsi asennetut fontit uudelleen" +"text.set" = "Aseta teksti" +"text.setFeatures" = "OpenType-ominaisuudet" +"text.setVariations" = "Muuttuvan fontin akselit" +"text.sizeDown" = "Pienennä fonttikokoa" +"text.sizeUp" = "Suurenna fonttikokoa" +"text.strikethrough" = "Yliviivaus" +"text.style" = "Muotoile tekstialue" +"text.trackingDown" = "Pienennä merkkiväliä" +"text.trackingUp" = "Suurenna merkkiväliä" +"text.underline" = "Alleviivaus" +"text.weightDown" = "Vähennä fontin paksuutta" +"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ä" +"tool.pencilBrush" = "Lyijykynäsivellin" +"tool.place" = "Sijoita työkalulla" +"tool.polygon" = "Monikulmio" +"tool.rectangle" = "Suorakulmio" +"tool.scale" = "Skaalaus" +"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" +"transform.nudge" = "Siirrä askelittain" +"transform.nudgeSize" = "Muuta kokoa askelittain" +"transform.resize" = "Muuta kokoa" +"transform.rotate" = "Kierrä" +"transform.rotationOrigin" = "Siirrä kiertokeskipistettä" +"transform.scale" = "Skaalaus" +"transform.setRotationOrigin" = "Aseta kiertokeskipiste" +"variable.addMode" = "Lisää tila" +"variable.bind" = "Sido muuttuja" +"variable.create" = "Luo muuttuja" +"variable.createCollection" = "Luo muuttujakokoelma" +"variable.delete" = "Poista muuttuja" +"variable.deleteCollection" = "Poista kokoelma" +"variable.duplicate" = "Monista muuttuja" +"variable.export" = "Vie muuttujat" +"variable.import" = "Tuo muuttujat" +"variable.list" = "Luettele muuttujat" +"variable.moveMode" = "Siirrä tila" +"variable.removeMode" = "Poista tila" +"variable.renameCollection" = "Nimeä kokoelma uudelleen" +"variable.renameMode" = "Nimeä tila uudelleen" +"variable.resetOverride" = "Palauta muuttujan ohitus" +"variable.set" = "Aseta muuttujan arvo" +"variable.setMode" = "Aseta muuttujan tila" +"variable.update" = "Muokkaa muuttujaa" +"vector.create" = "Vektori" +"vector.deleteHeal" = "Poista ja korjaa valinta" +"vector.edit.addVertex" = "Lisää piste" +"vector.edit.begin" = "Muokkaa vektoria" +"vector.edit.bend" = "Taivuta segmenttiä" +"vector.edit.bounds" = "Valittujen pisteiden rajat" +"vector.edit.connect" = "Yhdistä pisteet" +"vector.edit.cornerRadius" = "Pisteen kulmasäde" +"vector.edit.cut" = "Leikkaa polku" +"vector.edit.delete" = "Poista pisteet" +"vector.edit.end" = "Lopeta muokkaus" +"vector.edit.erase" = "Pyyhi" +"vector.edit.lasso" = "Valitse lassolla" +"vector.edit.mirroring" = "Kahvojen peilaus" +"vector.edit.moveHandles" = "Siirrä kahvoja" +"vector.edit.moveVertices" = "Siirrä pisteitä" +"vector.edit.nudge" = "Siirrä pisteitä askelittain" +"vector.edit.paint" = "Maalipurkki" +"vector.edit.pen" = "Kynän piste" +"vector.edit.penEnd" = "Päätä kynäpolku" +"vector.edit.position" = "Pisteen sijainti" +"vector.edit.resize" = "Muuta pisteiden kokoa" +"vector.edit.rotate" = "Kierrä pisteitä" +"vector.edit.select" = "Valitse pisteitä" +"vector.edit.selectAll" = "Valitse kaikki pisteet" +"vector.edit.setHandle" = "Aseta kahva" +"vector.edit.smooth" = "Pehmennä tai terävöitä pisteitä" +"vector.edit.state" = "Vektorimuokkauksen tila" +"vector.edit.strokeCap" = "Pisteen viivanpää" +"vector.edit.strokeJoin" = "Pisteen viivaliitos" +"vector.edit.tool" = "Vektorimuokkauksen työkalu" +"vector.edit.transform" = "Muunna pisteitä" +"vector.edit.widthPoint" = "Leveyspiste" +"vector.flatten" = "Yhdistä vektorimuodoksi" +"vector.join" = "Yhdistä valinta" +"vector.offset" = "Siirrä polun reunaa" +"vector.outlineStroke" = "Muunna reunaviiva poluksi" +"vector.reverse" = "Käännä polun suunta" +"vector.simplify" = "Yksinkertaista vektori" +"vector.smoothJoin" = "Yhdistä valinta pehmeästi" +"version.autosave" = "Tallenna versio automaattisesti" +"version.delete" = "Poista versio" +"version.duplicate" = "Monista versio" +"version.exitPreview" = "Takaisin nykyiseen versioon" +"version.list" = "Luettele versiot" +"version.preview" = "Katsele versiota" +"version.rename" = "Nimeä versio uudelleen" +"version.restore" = "Palauta versio" +"view.assets" = "Resurssipaneeli" +"view.comments" = "Kommentit" +"view.contextMenu" = "Näytä pikavalikko" +"view.design" = "Suunnittelupaneeli" +"view.designPrototype" = "Vaihda suunnittelun ja prototyypin välillä" +"view.devMode" = "Kehittäjätila" +"view.frameOutlines" = "Kehysten ääriviivat" +"view.fullscreen" = "Koko näyttö" +"view.get" = "Hae näkymä" +"view.layers" = "Tasopaneeli" +"view.layoutGrids" = "Asetteluruudukot" +"view.leftSidebar" = "Näytä/kätke vasen sivupalkki" +"view.maskOutlines" = "Maskien ääriviivat" +"view.multiplayerCursors" = "Yhteiskäytön kohdistimet" +"view.nextFrame" = "Zoomaa seuraavaan kehykseen" +"view.nextPage" = "Seuraava sivu" +"view.nextRegion" = "Kohdista seuraava alue" +"view.outlines" = "Ääriviivat" +"view.outlinesHiddenLayers" = "Sisällytä kätketyt tasot ääriviivoihin" +"view.outlinesObjectBounds" = "Sisällytä objektien rajat ääriviivoihin" +"view.outlinesSelection" = "Valinnan ääriviivat" +"view.pan" = "Siirrä näkymää" +"view.panelWidths" = "Palauta paneelien leveydet" +"view.pixelGrid" = "Pikseliruudukko" +"view.pixelPreview" = "Pikseliesikatselu" +"view.prevFrame" = "Zoomaa edelliseen kehykseen" +"view.prevPage" = "Edellinen sivu" +"view.previousRegion" = "Kohdista edellinen alue" +"view.proofSrgb" = "sRGB-vedossimulointi" +"view.prototype" = "Prototyyppipaneeli" +"view.rulers" = "Viivaimet" +"view.set" = "Aseta näkymä" +"view.slices" = "Viipaleet" +"view.snapGeometry" = "Tartu geometriaan" +"view.snapObjects" = "Tartu objekteihin" +"view.snapPixel" = "Tartu pikseliruudukkoon" +"view.themeDark" = "Tumma teema" +"view.themeLight" = "Vaalea teema" +"view.themeSystem" = "Seuraa järjestelmän teemaa" +"view.toggleUi" = "Näytä/kätke käyttöliittymä" +"view.zoom100" = "Zoomaa kokoon 100 %" +"view.zoom200" = "Zoomaa kokoon 200 %" +"view.zoom50" = "Zoomaa kokoon 50 %" +"view.zoomAt" = "Zoomaustyökalu" +"view.zoomFit" = "Sovita näkymään" +"view.zoomIn" = "Lähennä" +"view.zoomOut" = "Loitonna" +"view.zoomSelection" = "Zoomaa valintaan" +"widget.click" = "Napsauta pienoisohjelmaa" +"widget.insert" = "Lisää pienoisohjelma" +"widget.list" = "Luettele pienoisohjelmat" +"window.focus" = "Tuo ikkuna etummaiseksi" +"window.new" = "Uusi ikkuna" + +[menu] +"bool.exclude" = "Jätä pois" +"bool.flatten" = "Yhdistä vektorimuodoksi" +"bool.intersect" = "Leikkaus" +"bool.subtract" = "Vähennä" +"bool.union" = "Yhdiste" +"file.duplicate" = "Monista" +"file.import" = "Tuo tiedostosta…" +"file.rename" = "Nimeä uudelleen" +"libraries.open" = "Avaa kirjastot" +"select.sameEffect" = "Tehoste" +"select.sameFill" = "Täyttö" +"select.sameFont" = "Fontti" +"select.sameInstance" = "Ilmentymä" +"select.sameProperties" = "Ominaisuudet" +"select.sameStroke" = "Reunaviiva" +"select.sameText" = "Tekstiominaisuudet" +"text.alignCenter" = "Keskitä teksti" +"text.alignJustify" = "Tasaa tekstin molemmat reunat" +"text.alignLeft" = "Tasaa teksti vasemmalle" +"text.alignRight" = "Tasaa teksti oikealle" +"view.assets" = "Resurssit" +"view.design" = "Suunnittelu" +"view.layers" = "Tasot" +"view.outlines" = "Näytä ääriviivat" +"view.outlinesHiddenLayers" = "Sisällytä kätketyt tasot" +"view.outlinesObjectBounds" = "Sisällytä objektien rajat" +"view.outlinesSelection" = "Näytä valinnan ääriviivat" +"view.prototype" = "Prototyyppi" +"view.themeDark" = "Tumma" +"view.themeLight" = "Vaalea" +"view.themeSystem" = "Seuraa järjestelmää" diff --git a/crates/ui-egui/locales/fr.toml b/crates/ui-egui/locales/fr.toml index 5edf1a62..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" @@ -318,6 +322,7 @@ status = "draft — needs native review" "Corner smoothing" = "Lissage des angles" "Couldn't download the variables" = "Impossible de télécharger les variables" "Couldn't reach {server}: showing what this computer remembers." = "Impossible de joindre {server} : affichage de ce dont cet ordinateur se souvient." +"Couldn't read {name}." = "Impossible de lire {name}." "Couldn't save automatically: {error}" = "Impossible d’enregistrer automatiquement : {error}" "Count" = "Nombre" "Create" = "Créer" @@ -376,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" @@ -387,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" @@ -403,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" @@ -438,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" @@ -574,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" @@ -586,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" @@ -652,6 +663,7 @@ status = "draft — needs native review" "Interaction" = "Interaction" "Interactions" = "Interactions" "Interface design and prototyping. A free, open-source, clean-room design tool written from scratch in Rust, part of the ArtCraft Crafting Apps." = "Design d’interfaces et prototypage. Un outil de design libre et gratuit, développé en salle blanche (clean-room) et écrit de zéro en Rust, membre des Crafting Apps d’ArtCraft." +"Interface size" = "Taille de l’interface" "Intersect" = "Intersection" "Intersects" = "Intersections" "Invalid color" = "Couleur non valide" @@ -752,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)" @@ -810,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" @@ -875,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." @@ -912,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" @@ -947,9 +963,11 @@ status = "draft — needs native review" "PDF print settings" = "Réglages d’impression PDF" "Padding" = "Marge intérieure" "Page" = "Page" +"Page deleted" = "Page supprimée" "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" @@ -994,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" @@ -1041,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" @@ -1058,6 +1078,7 @@ status = "draft — needs native review" "Reduce motion" = "Réduire les animations" "Refraction" = "Réfraction" "Regular expression" = "Expression régulière" +"Relative to your system's display scaling (100% is the system's own size). Document zoom is separate." = "Relative à la mise à l’échelle d’affichage de votre système (100 % est la taille du système). Le zoom du document est distinct." "Remove" = "Retirer" "Remove all" = "Tout retirer" "Remove annotation" = "Retirer l’annotation" @@ -1086,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" @@ -1141,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" @@ -1214,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" @@ -1280,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" @@ -1293,18 +1320,23 @@ status = "draft — needs native review" "Start from a blank canvas" = "Commencer avec un canevas vide" "Start from a template" = "Commencer avec un modèle" "Start on path" = "Début sur le tracé" +"Start on the Home screen" = "Démarrer sur l’Accueil" "Start point" = "Point de départ" "Start sharing" = "Commencer le partage" "Start sharing with the Share button, then pass on the code it gives" = "Commencez à partager avec le bouton Partager, puis communiquez le code qu’il donne" "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" @@ -1349,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" @@ -1366,6 +1399,7 @@ status = "draft — needs native review" "The selection uses several text styles: click to apply one" = "La sélection utilise plusieurs styles de texte : cliquez pour en appliquer un" "The server deletes this file if nobody opens it before {date}." = "Le serveur supprime ce fichier si personne ne l’ouvre avant le {date}." "The whole file" = "Le fichier entier" +"The window is too small for {size}%, so the interface is drawn at {drawn}%. Make the window larger to use {size}%." = "La fenêtre est trop petite pour {size} %, l’interface est donc affichée à {drawn} %. Agrandissez la fenêtre pour utiliser {size} %." "Theme" = "Thème" "There's no destination for that action" = "Cette action n’a pas de destination" "Thickness" = "Épaisseur" @@ -1382,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" @@ -1398,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" @@ -1422,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" @@ -1443,6 +1481,7 @@ status = "draft — needs native review" "Use number keys for opacity" = "Utiliser les touches numériques pour l’opacité" "Use the .figcraft or .fig extension." = "Utilisez l’extension .figcraft ou .fig." "Use the layer's fill" = "Utiliser le remplissage du calque" +"Use your browser's zoom to change the interface size." = "Utilisez le zoom de votre navigateur pour modifier la taille de l’interface." "Used in {files} · {instances}" = "Utilisé dans {files} · {instances}" "Username" = "Nom d’utilisateur" "Uses variables: each layer shows them in its own mode" = "Utilise des variables : chaque calque les affiche dans son propre mode" @@ -1487,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" @@ -1665,6 +1706,7 @@ status = "draft — needs native review" "{name} is forgotten on this computer; the file stays on {server}" = "{name} est oublié sur cet ordinateur ; le fichier reste sur {server}" "{name} is on the server." = "{name} est sur le serveur." "{name} is spotlighting" = "{name} est sous les projecteurs" +"{name} is too large to open in the browser (over 512 MB)." = "{name} est trop volumineux pour être ouvert dans le navigateur (plus de 512 Mo)." "{name} was removed from the server" = "{name} a été retiré du serveur" "{name} · deleted on main" = "{name} · supprimé dans le principal" "{name} · deleted on the branch" = "{name} · supprimé dans la branche" @@ -1710,6 +1752,7 @@ status = "draft — needs native review" "{n}m" = "{n} min" "{n}w" = "{n} sem." "{page}, nothing selected" = "{page}, aucune sélection" +"{percent}%" = "{percent} %" "{profile} (current)" = "{profile} (actuel)" "{property} is its value; {property ? \"a\" : \"b\"} picks a string. Other {…} stays as written." = "{property} donne sa valeur ; {property ? \"a\" : \"b\"} choisit un texte. Tout autre {…} reste tel quel." "{server} can't say what became of your files (it is older than this app)." = "{server} ne peut pas dire ce que sont devenus vos fichiers (il est plus ancien que cette application)." @@ -1755,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" @@ -1879,7 +1931,9 @@ status = "draft — needs native review" "file.exportLayers" = "Exporter les calques" "file.exportSvg" = "Exporter la sélection en SVG" "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" @@ -2021,6 +2075,8 @@ status = "draft — needs native review" "pref.reduceMotion" = "Réduire les animations" "pref.renameDuplicates" = "Renommer les calques dupliqués" "pref.showDimensions" = "Afficher les dimensions sur les objets" +"pref.startOnHome" = "Démarrer sur l’Accueil" +"pref.uiScale" = "Taille de l’interface" "pref.userName" = "Votre nom dans les commentaires" "proto.action.add" = "Ajouter une action de prototype" "proto.action.move" = "Réordonner l’action de prototype" @@ -2149,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" @@ -2164,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" @@ -2228,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 65a9939c..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" @@ -314,6 +317,7 @@ status = "draft — needs native review" "Corner radius" = "Radius sudut" "Corner smoothing" = "Penghalusan sudut" "Couldn't reach {server}: showing what this computer remembers." = "Tidak dapat terhubung ke {server}: menampilkan data yang diingat komputer ini." +"Couldn't read {name}." = "Tidak dapat membaca {name}." "Count" = "Jumlah" "Create" = "Buat" "Create a variable collection first" = "Buat koleksi variabel terlebih dahulu" @@ -371,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" @@ -398,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" @@ -430,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" @@ -565,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" @@ -577,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" @@ -742,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)" @@ -800,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" @@ -901,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" @@ -933,8 +945,10 @@ status = "draft — needs native review" "PDF print settings" = "Pengaturan cetak PDF" "Padding" = "Padding" "Page" = "Halaman" +"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" @@ -977,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" @@ -1025,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" @@ -1124,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" @@ -1196,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" @@ -1262,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" @@ -1281,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" @@ -1331,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" @@ -1361,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" @@ -1377,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" @@ -1401,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" @@ -1633,6 +1661,7 @@ status = "draft — needs native review" "{name} is forgotten on this computer; the file stays on {server}" = "{name} dilupakan di komputer ini; file tetap ada di {server}" "{name} is on the server." = "{name} ada di server." "{name} is spotlighting" = "{name} sedang spotlight" +"{name} is too large to open in the browser (over 512 MB)." = "{name} terlalu besar untuk dibuka di browser (lebih dari 512 MB)." "{name}\nClick to apply to the selection" = "{name}\nKlik untuk menerapkan ke pilihan" "{name}\nDrag onto the canvas, or click to place in the middle of the view.\nHold Alt (Option) while dropping onto an instance to swap it." = "{name}\nSeret ke kanvas, atau klik untuk menaruh di tengah tampilan.\nTahan Alt (Option) saat melepas di atas instance untuk menukarnya." "{name} (from {library})" = "{name} (dari {library})" @@ -1730,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" @@ -1853,7 +1891,9 @@ status = "draft — needs native review" "file.exportLayers" = "Ekspor layer" "file.exportSvg" = "Ekspor pilihan sebagai SVG" "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" @@ -2120,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" @@ -2135,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" @@ -2199,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 def8ebc4..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" = "コンテンツ" @@ -316,6 +320,7 @@ status = "draft — needs native review" "Corner radius" = "角の半径" "Corner smoothing" = "角のスムージング" "Couldn't reach {server}: showing what this computer remembers." = "{server} に接続できない:このコンピューターが覚えている内容を表示中。" +"Couldn't read {name}." = "{name} を読み込めませんでした。" "Couldn't save automatically: {error}" = "自動保存できませんでした:{error}" "Count" = "数" "Create" = "作成" @@ -374,6 +379,7 @@ status = "draft — needs native review" "Description" = "説明" "Description (optional)" = "説明(任意)" "Design" = "デザイン" +"Design panel" = "デザインパネル" "Design system starter" = "デザインシステムの雛形" "Desktop" = "デスクトップ" "Destination" = "遷移先" @@ -385,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" = "デバイス" @@ -401,6 +408,7 @@ status = "draft — needs native review" "Distribute horizontal spacing" = "水平方向に等間隔に分布" "Distribute vertical spacing" = "垂直方向に等間隔に分布" "Divider" = "区切り線" +"Don't Save" = "保存しない" "Don't truncate" = "切り詰めない" "Done" = "完了" "Done editing" = "編集を完了" @@ -433,6 +441,7 @@ status = "draft — needs native review" "Effect settings" = "エフェクトの設定" "Effect styles" = "エフェクトスタイル" "Effects" = "エフェクト" +"Elbow" = "直角" "Ellipse" = "楕円" "Ellipses" = "楕円" "Else" = "それ以外" @@ -569,6 +578,7 @@ status = "draft — needs native review" "Hanging list" = "ぶら下げリスト" "Hanging punctuation" = "ぶら下げ句読点" "Hard light" = "ハードライト" +"Heart" = "ハート" "Height" = "高さ" "Help" = "ヘルプ" "Help and resources" = "ヘルプとリソース" @@ -581,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 を押したまま" @@ -747,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)" = "一致(空欄:名前全体)" @@ -805,6 +817,7 @@ status = "draft — needs native review" "Named instance" = "名前付きインスタンス" "Navigate to" = "移動先" "Nested instance" = "ネストされたインスタンス" +"New board" = "新規ボード" "New design file" = "新規デザインファイル" "Newest first" = "新しい順" "Next frame" = "次のフレーム" @@ -869,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." = "オンラインのファイルはまだない。ファイルの「共有」からオンラインにすると、ここに表示される。" @@ -906,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 で開く" @@ -938,8 +953,10 @@ status = "draft — needs native review" "PDF print settings" = "PDF 印刷設定" "Padding" = "パディング" "Page" = "ページ" +"Page deleted" = "ページは削除済み" "Pages" = "ページ" "Pages added to the document" = "ページをドキュメントに追加した" +"Pink" = "ピンク" "Placed on the current page" = "現在のページに配置した" "Pages: {pages}" = "ページ:{pages}" "Paint bucket" = "塗りつぶし" @@ -983,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" = "多角形" @@ -1030,6 +1048,7 @@ status = "draft — needs native review" "Push" = "プッシュ" "Push changes to main component" = "変更をメインコンポーネントにプッシュ" "Put text on path" = "テキストをパスに沿わせる" +"Question" = "質問" "R" = "R" "Radial" = "放射状" "Radius" = "半径" @@ -1075,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" = "フォルダー名を変更" @@ -1130,6 +1150,7 @@ status = "draft — needs native review" "Rotation jitter" = "回転のばらつき" "Round" = "丸型" "Rounded" = "丸型" +"Rounded rectangle" = "角丸長方形" "Row" = "行" "Row gap" = "行の間隔" "Row sizes" = "行のサイズ" @@ -1203,6 +1224,7 @@ status = "draft — needs native review" "Shape" = "シェイプ" "Shape builder" = "シェイプビルダー" "Shape tools" = "シェイプツール" +"Shapes" = "シェイプ" "Share" = "共有" "Share this file" = "このファイルを共有" "Share with a code" = "コードで共有" @@ -1269,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" = "星" @@ -1282,18 +1307,23 @@ status = "draft — needs native review" "Start from a blank canvas" = "空白のキャンバスから始める" "Start from a template" = "テンプレートから始める" "Start on path" = "パス上の開始位置" +"Start on the Home screen" = "起動時にホームを表示" "Start point" = "始点" "Start sharing" = "共有を開始" "Start sharing with the Share button, then pass on the code it gives" = "共有ボタンで共有を開始し、表示されたコードを相手に伝える" "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" = "文字列" @@ -1338,6 +1368,7 @@ status = "draft — needs native review" "Text" = "テキスト" "Text align" = "テキストの配置" "Text content" = "テキストの内容" +"Text size" = "テキストサイズ" "Text style" = "テキストスタイル" "Text styles" = "テキストスタイル" "Texts" = "テキスト" @@ -1368,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" = "タイルの種類" @@ -1384,6 +1418,7 @@ status = "draft — needs native review" "Top left radius" = "左上の半径" "Top right radius" = "右上の半径" "Transform" = "変形" +"Triangle" = "三角形" "Triangle arrow" = "三角矢印" "Trigger" = "トリガー" "True" = "true" @@ -1408,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" = "上" @@ -1473,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" = "白" @@ -1650,6 +1687,7 @@ status = "draft — needs native review" "{name} is forgotten on this computer; the file stays on {server}" = "{name} の記録をこのコンピューターから消去済み。ファイルは {server} に残る" "{name} is on the server." = "{name} はサーバーにある。" "{name} is spotlighting" = "{name} がスポットライト中" +"{name} is too large to open in the browser (over 512 MB)." = "{name} は大きすぎるため、ブラウザーで開けません(512 MB 超)。" "{name} was removed from the server" = "{name} をサーバーから削除済み" "{name} · deleted on main" = "{name} · メインで削除" "{name} · deleted on the branch" = "{name} · ブランチで削除" @@ -1740,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" = "選択範囲を交差" @@ -1864,7 +1911,9 @@ status = "draft — needs native review" "file.exportLayers" = "レイヤーを書き出し" "file.exportSvg" = "選択範囲を SVG で書き出し" "file.import" = "インポート" +"file.moveTab" = "タブを移動" "file.new" = "新規デザインファイル" +"file.newBoard" = "新規ボード" "file.newFromTemplate" = "テンプレートから新規作成" "file.open" = "開く" "file.openRecent" = "最近使ったファイルを開く" @@ -2005,6 +2054,7 @@ status = "draft — needs native review" "pref.reduceMotion" = "動きを減らす" "pref.renameDuplicates" = "複製したレイヤーの名前を変更" "pref.showDimensions" = "オブジェクトに寸法を表示" +"pref.startOnHome" = "起動時にホームを表示" "pref.userName" = "コメントに表示する名前" "proto.action.add" = "プロトタイプのアクションを追加" "proto.action.move" = "プロトタイプのアクションの順序を変更" @@ -2133,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" = "鉛筆" @@ -2148,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" = "指定位置へ移動" @@ -2212,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 new file mode 100644 index 00000000..42f78be6 --- /dev/null +++ b/crates/ui-egui/locales/ka.toml @@ -0,0 +1,2387 @@ +# FigCraft interface strings: Georgian. +# +# Written with AI assistance (Claude) from the meaning of each English string, following the glossary +# the Craft apps share, and reviewed by a native Georgian speaker; kept as a draft until a maintainer +# changes its status. Brand names and keyboard glyphs stay as they are. Georgian has no letter case, +# and a noun after a numeral stays singular, so counts read "შრე: {n}" and plural entries give the +# same text for one and other. See docs/translating.md. + +[meta] +name = "ქართული" +status = "draft — reviewed by a native speaker, awaiting a maintainer's approval" + +[ui] +"% of all commits" = "ყველა კომიტის %" +"(no longer published)" = "(აღარ არის გამოქვეყნებული)" +"({n} warnings: some entries were skipped)" = { one = "(გაფრთხილება: {n}; ზოგი ჩანაწერი გამოტოვებულია)", other = "(გაფრთხილება: {n}; ზოგი ჩანაწერი გამოტოვებულია)" } +"+ Create variable" = "+ ცვლადის შექმნა" +".fig (experimental)" = ".fig (ექსპერიმენტული)" +"1…9, 0" = "1…9, 0" +"@{login}: {prs} PRs, {commits} commits, +{added} / −{deleted} lines (Δ {delta}), +{bin_added} / −{bin_deleted} binary assets, {first} – {last}" = "@{login}: PR: {prs}, კომიტი: {commits}, +{added} / −{deleted} სტრიქონი (Δ {delta}), +{bin_added} / −{bin_deleted} ბინარული რესურსი, {first} – {last}" +"A collection of this file with these modes and variables: override their values per mode and add variables of its own" = "ამ ფაილის კოლექცია ამ რეჟიმებითა და ცვლადებით: შეცვალეთ მათი მნიშვნელობები თითოეული რეჟიმისთვის და დაამატეთ საკუთარი ცვლადები" +"A .figcraft file with this name already exists. Choose its full name to confirm replacement." = "ამ სახელით .figcraft ფაილი უკვე არსებობს. ჩანაცვლების დასადასტურებლად აირჩიეთ მისი სრული სახელი." +"A layer on its own aligns to its frame: select more layers, or one in a frame" = "ცალკე შრე თავის ჩარჩოსთან სწორდება: მონიშნეთ მეტი შრე ან ჩარჩოში მყოფი შრე" +"A responsive product page: hero, features, figures, a quote and a footer" = "პროდუქტის ადაპტური გვერდი: მთავარი ბლოკი, შესაძლებლობები, ციფრები, ციტატა და ქვედა ზოლი" +"A spring plays until it settles: its mass, stiffness and damping set how long" = "ზამბარა მოძრაობს, სანამ არ დამშვიდდება: მისი მასა, სიხისტე და მილევა განსაზღვრავს ხანგრძლივობას" +"A title, an agenda, a big figure, a chart and a closing slide" = "სათაური, დღის წესრიგი, დიდი ციფრი, დიაგრამა და დასკვნითი სლაიდი" +"A to Z" = "ა-დან ჰ-მდე" +"About" = "პროგრამის შესახებ" +"About FigCraft" = "FigCraft-ის შესახებ" +"About this server" = "ამ სერვერის შესახებ" +"Action" = "მოქმედება" +"Actions" = "მოქმედებები" +"Actions for {name}" = "მოქმედებები: {name}" +"Actions…" = "მოქმედებები…" +"Active" = "აქტიური" +"Actual size (100%)" = "რეალური ზომა (100%)" +"Add a category" = "კატეგორიის დამატება" +"Add a comment" = "კომენტარის დამატება" +"Add a description" = "აღწერის დამატება" +"Add a frame (F) or a shape (R) to get started" = "დასაწყებად დაამატეთ ჩარჩო (F) ან ფიგურა (R)" +"Add a layer or component to tile first" = "ჯერ დაამატეთ შრე ან კომპონენტი ფილებად დასაწყობად" +"Add a note for developers" = "შენიშვნის დამატება დეველოპერებისთვის" +"Add action" = "მოქმედების დამატება" +"Add actions for when no condition holds" = "მოქმედებების დამატება იმ შემთხვევისთვის, როცა არცერთი პირობა არ სრულდება" +"Add an annotation for developers" = "ანოტაციის დამატება დეველოპერებისთვის" +"Add another condition" = "კიდევ ერთი პირობის დამატება" +"Add auto layout" = "ავტოგანლაგების დამატება" +"Add background behind overlay" = "ფონის დამატება გადაფარვის უკან" +"Add content to this slot" = "შიგთავსის დამატება ამ სლოტში" +"Add dev resource" = "დეველოპერის რესურსის დამატება" +"Add effect" = "ეფექტის დამატება" +"Add export setting" = "ექსპორტის პარამეტრის დამატება" +"Add fill" = "შევსების დამატება" +"Add interaction" = "ურთიერთქმედების დამატება" +"Add layout grid" = "განლაგების ბადის დამატება" +"Add library" = "ბიბლიოთეკის დამატება" +"Add library file…" = "ბიბლიოთეკის ფაილის დამატება…" +"Add max height" = "მაქსიმალური სიმაღლის დამატება" +"Add max width" = "მაქსიმალური სიგანის დამატება" +"Add min height" = "მინიმალური სიმაღლის დამატება" +"Add min width" = "მინიმალური სიგანის დამატება" +"Add mode" = "რეჟიმის დამატება" +"Add new page" = "ახალი გვერდის დამატება" +"Add or update collections from a variables JSON" = "კოლექციების დამატება ან განახლება ცვლადების JSON-იდან" +"Add properties to make instances configurable" = "დაამატეთ თვისებები, რომ ინსტანსები კონფიგურირებადი გახდეს" +"Add reaction" = "რეაქციის დამატება" +"Add starting point" = "საწყისი წერტილის დამატება" +"Add stroke" = "კონტურის დამატება" +"Add variant" = "ვარიანტის დამატება" +"Add variant with a new value" = "ვარიანტის დამატება ახალი მნიშვნელობით" +"Added" = "დამატებულია" +"Added the library \"{name}\"" = "ბიბლიოთეკა „{name}“ დამატებულია" +"Adding to {name}" = "დამატება: {name}" +"Adjust" = "კორექცია" +"Adjustment presets" = "კორექციის პრესეტები" +"Advanced" = "დამატებითი" +"Advanced stroke" = "კონტურის დამატებითი პარამეტრები" +"After delay" = "დაყოვნების შემდეგ" +"After delay {ms}ms" = "დაყოვნების შემდეგ: {ms} მწმ" +"Align" = "სწორება" +"Align bottom" = "ქვემოთ სწორება" +"Align center" = "ცენტრში სწორება" +"Align horizontal centers" = "ჰორიზონტალური ცენტრების სწორება" +"Align left" = "მარცხნივ სწორება" +"Align middle" = "შუაში სწორება" +"Align right" = "მარჯვნივ სწორება" +"Align text baselines" = "ტექსტის საბაზისო ხაზების სწორება" +"Align top" = "ზემოთ სწორება" +"Align vertical centers" = "ვერტიკალური ცენტრების სწორება" +"Alignment" = "სწორება" +"All" = "ყველა" +"All categories" = "ყველა კატეგორია" +"All comments" = "ყველა კომენტარი" +"All components" = "ყველა კომპონენტი" +"All fonts" = "ყველა შრიფტი" +"All supported properties" = "ყველა მხარდაჭერილი თვისება" +"All the fonts this file uses are installed" = "ამ ფაილის ყველა შრიფტი დაინსტალირებულია" +"All variables" = "ყველა ცვლადი" +"Alpha" = "ალფა" +"Alphabetical" = "ანბანის მიხედვით" +"Alt+drag" = "Alt+გადათრევა" +"An analytics screen on a grid: figures, charts drawn from vectors and a table" = "ანალიტიკის ეკრანი ბადეზე: ციფრები, ვექტორებით დახატული დიაგრამები და ცხრილი" +"An invite link that opens FigCraft and joins" = "მოსაწვევი ბმული, რომელიც ხსნის FigCraft-ს და უერთდება სესიას" +"Anchor" = "საყრდენი" +"Android" = "Android" +"Angular" = "კუთხური" +"Animation" = "ანიმაცია" +"Annotations" = "ანოტაციები" +"Annotations shown on the canvas" = "ტილოზე ნაჩვენები ანოტაციები" +"Another server, or one that needs its key. The usual server is set in Preferences." = "სხვა სერვერი ან სერვერი, რომელსაც გასაღები სჭირდება. ჩვეულებრივი სერვერი პარამეტრებში ირჩევა." +"Any status" = "ნებისმიერი სტატუსი" +"Anyone on your network with this code can edit the file, while this app stays open." = "თქვენს ქსელში ნებისმიერს, ვისაც ეს კოდი აქვს, შეუძლია ფაილის რედაქტირება, სანამ ეს აპი ღიაა." +"Anyone with the edit link can change the file; the view link only lets people look. The links carry their secrets: share them like passwords. `figcraft-server links` makes more and revokes them." = "რედაქტირების ბმულის მქონე ნებისმიერს შეუძლია ფაილის შეცვლა; ნახვის ბმული მხოლოდ დათვალიერების საშუალებას იძლევა. ბმულები საიდუმლო გასაღებებს შეიცავს: გააზიარეთ ისინი პაროლების მსგავსად. `figcraft-server links` ქმნის ახალ ბმულებს და აუქმებს მათ." +"Anyone with this code can edit the file." = "ნებისმიერს, ვისაც ეს კოდი აქვს, შეუძლია ფაილის რედაქტირება." +"Anyone with this code can look at the file, not change it." = "ნებისმიერს, ვისაც ეს კოდი აქვს, შეუძლია ფაილის ნახვა, მაგრამ არა შეცვლა." +"Appearance" = "იერსახე" +"Apply a component property" = "კომპონენტის თვისების გამოყენება" +"Apply variable" = "ცვლადის გამოყენება" +"Apply variable to text" = "ცვლადის გამოყენება ტექსტზე" +"Apply variable to visibility" = "ცვლადის გამოყენება ხილვადობაზე" +"Apps" = "აპები" +"Apr" = "აპრ" +"Arc" = "რკალი" +"Arc ratio" = "რკალის თანაფარდობა" +"Arc start" = "რკალის დასაწყისი" +"Arc sweep" = "რკალის სიგრძე" +"Archive" = "დაარქივება" +"Archived" = "დაარქივებულია" +"Arrange" = "დალაგება" +"Arrow" = "ისარი" +"Arrow at the end" = "ისარი ბოლოში" +"As typed" = "როგორც აიკრიფა" +"Asking the server…" = "სერვერთან მოთხოვნა…" +"Assets" = "რესურსები" +"Aug" = "აგვ" +"Auto" = "ავტომატური" +"Auto (space around)" = "ავტო (დაშორება გარშემო)" +"Auto (space between)" = "ავტო (დაშორება შორის)" +"Auto (space evenly)" = "ავტო (თანაბარი დაშორება)" +"Auto ({mode})" = "ავტო ({mode})" +"Auto columns" = "ავტომატური სვეტები" +"Auto gap" = "ავტომატური შუალედი" +"Auto height" = "ავტომატური სიმაღლე" +"Auto layout" = "ავტოგანლაგება" +"Auto rows" = "ავტომატური სტრიქონები" +"Auto width" = "ავტომატური სიგანე" +"Back to the FigCraft server" = "FigCraft-ის სერვერზე დაბრუნება" +"Back to the font's value" = "შრიფტის მნიშვნელობაზე დაბრუნება" +"Back" = "უკან" +"Back to Ready for dev" = "„მზადაა დეველოპერისთვის“ სტატუსზე დაბრუნება" +"Back to Ready for dev: the next change compares with how it is now" = "„მზადაა დეველოპერისთვის“ სტატუსზე დაბრუნება: შემდეგი ცვლილება შედარდება ამჟამინდელ მდგომარეობას" +"Back to assets" = "რესურსებზე დაბრუნება" +"Back to current" = "მიმდინარეზე დაბრუნება" +"Back to styles" = "სტილებზე დაბრუნება" +"Back to the canvas from a focused region" = "ფოკუსირებული არიდან ტილოზე დაბრუნება" +"Back to the current version" = "მიმდინარე ვერსიაზე დაბრუნება" +"Back to the values the font style implies" = "შრიფტის სტილით განსაზღვრულ მნიშვნელობებზე დაბრუნება" +"Background" = "ფონი" +"Background blur" = "ფონის გაბუნდოვნება" +"Basics" = "საფუძვლები" +"Before" = "მანამდე" +"Bend" = "მოხრა" +"Bevel" = "წაკვეთილი" +"Binary assets added" = "დამატებული ბინარული ფაილები" +"Binary assets removed" = "წაშლილი ბინარული ფაილები" +"Black & white" = "შავ-თეთრი" +"Bleed" = "ჭრისპირა არე" +"Bleed marks" = "ჭრისპირა არის ნიშნები" +"Bleed marks need a bleed" = "ჭრისპირა არის ნიშნებს ჭრისპირა არე სჭირდება" +"Blend" = "შერევა" +"Blend mode" = "შერევის რეჟიმი" +"Blue" = "ლურჯი" +"Blur" = "გაბუნდოვნება" +"Boolean" = "ლოგიკური" +"Boolean groups" = "ლოგიკური ოპერაციების ჯგუფები" +"Both directions" = "ორივე მიმართულება" +"Bottom" = "ქვემოთ" +"Bottom left radius" = "ქვედა მარცხენა რადიუსი" +"Bottom right radius" = "ქვედა მარჯვენა რადიუსი" +"Branch" = "განშტოება" +"Branch archived" = "განშტოება დაარქივებულია" +"Branch merged" = "განშტოება შერწყმულია" +"Branch name" = "განშტოების სახელი" +"Branch of {file}" = "{file}-ის განშტოება" +"Branch of {file} · merged or archived" = "{file}-ის განშტოება · შერწყმული ან დაარქივებული" +"Branch updated from main" = "განშტოება განახლდა მთავარიდან" +"Branch {name}" = "განშტოება {name}" +"Branches" = "განშტოებები" +"Bound to {name}. Click to detach the variable." = "მიბმულია: {name}. დაწკაპეთ ცვლადის მოსაწყვეტად." +"Bring forward" = "წინ გადმოტანა" +"Bring the main file's changes into the branch" = "მთავარი ფაილის ცვლილებების განშტოებაში გადმოტანა" +"Bring to front" = "წინა პლანზე გადმოტანა" +"Bristle" = "ჯაგრისი" +"Browse" = "დათვალიერება" +"Brush" = "ფუნჯი" +"Brush rotation jitter" = "ფუნჯის შემობრუნების ცვალებადობა" +"Brush scatter" = "ფუნჯის გაბნევა" +"Brush size jitter" = "ფუნჯის ზომის ცვალებადობა" +"Brush spacing" = "ფუნჯის ბიჯი" +"Bulleted list" = "ბურთულებიანი სია" +"C or Shift+C" = "C ან Shift+C" +"C or ⇧C" = "C ან ⇧C" +"CMYK colour" = "CMYK ფერი" +"Can view" = "შეუძლია ნახვა" +"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-ის მორგებული თვისებები…" +"Can edit" = "შეუძლია რედაქტირება" +"Can't read this library: {e}" = "ამ ბიბლიოთეკის წაკითხვა ვერ მოხერხდა: {e}" +"Cancel" = "გაუქმება" +"Canvas" = "ტილო" +"Canvas stacking" = "ტილოზე დალაგების რიგი" +"Cap height to baseline" = "მთავრული ასოს სიმაღლიდან საბაზისო ხაზამდე" +"Case" = "რეგისტრი" +"Case-sensitive forms" = "რეგისტრზე დამოკიდებული ფორმები" +"Categories" = "კატეგორიები" +"Center" = "ცენტრში" +"Change alias" = "ალიასის შეცვლა" +"Change resolved" = "ცვლილება გადაწყვეტილია" +"Change style" = "სტილის შეცვლა" +"Change to" = "შეცვლა:" +"Change to needs a layer inside a variant" = "„ვარიანტის შეცვლას“ ვარიანტის შიგნით შრე სჭირდება" +"Changed" = "შეცვლილია" +"Changed on both sides: keep one" = "შეცვლილია ორივე მხარეს: დატოვეთ ერთი" +"Changes on main" = "ცვლილებები მთავარში" +"Changes on the branch" = "ცვლილებები განშტოებაში" +"Changes unknown" = "ცვლილებები უცნობია" +"Choose React, Leptos, SwiftUI or Compose above to connect this component to its code." = "ამ კომპონენტის კოდთან დასაკავშირებლად ზემოთ აირჩიეთ React, Leptos, SwiftUI ან Compose." +"Choose another profile to change it." = "მის შესაცვლელად აირჩიეთ სხვა პროფილი." +"Choose collection" = "კოლექციის არჩევა" +"Choose image" = "გამოსახულების არჩევა" +"Choose mode" = "რეჟიმის არჩევა" +"Choose variable" = "ცვლადის არჩევა" +"Circle" = "წრე" +"Clear recent" = "ბოლოდროინდელის გასუფთავება" +"Click + to replace mixed content" = "დაწკაპეთ +, რომ შერეული შიგთავსი ჩაანაცვლოთ" +"Click anywhere on the canvas to leave one." = "მის დასატოვებლად დაწკაპეთ ტილოს ნებისმიერ ადგილას." +"Click or drag to merge · {alt} removes" = "დაწკაპეთ ან გადაათრიეთ შესარწყმელად · {alt} შლის" +"Click or drag to remove · {alt} merges" = "დაწკაპეთ ან გადაათრიეთ წასაშლელად · {alt} შერწყამს" +"Click to record a key" = "დაწკაპეთ კლავიშის ჩასაწერად" +"Clip content" = "შიგთავსის მოჭრა" +"Clip to shape" = "ფიგურაზე მოჭრა" +"Close" = "დახურვა" +"Close overlay" = "გადაფარვის დახურვა" +"Close preview" = "წინასწარი ნახვის დახურვა" +"Close tab" = "ჩანართის დახურვა" +"Close version history" = "ვერსიების ისტორიის დახურვა" +"Close when clicking outside" = "დახურვა გარეთ დაწკაპუნებისას" +"Code" = "კოდი" +"Code syntax" = "კოდის სინტაქსი" +"Collapse" = "ჩაკეცვა" +"Collapse panel" = "პანელის ჩაკეცვა" +"Collection" = "კოლექცია" +"Collections" = "კოლექციები" +"Color" = "ფერი" +"Color burn" = "ფერით ჩამუქება" +"Color dodge" = "ფერით გაღიავება" +"Color format" = "ფერის ფორმატი" +"Color profile" = "ფერის პროფილი" +"Color styles" = "ფერის სტილები" +"Color values in this file are in this color space." = "ამ ფაილში ფერის მნიშვნელობები ამ ფერთა სივრცეშია." +"Color variables" = "ფერის ცვლადები" +"Colour" = "ფერი" +"Column" = "სვეტი" +"Column gap" = "სვეტებს შორის შუალედი" +"Column sizes" = "სვეტების ზომები" +"Column span" = "სვეტების გაერთიანება" +"Columns" = "სვეტები" +"Columns {count}" = "სვეტები {count}" +"Comment" = "კომენტარი" +"Comments" = "კომენტარები" +"Commits" = "კომიტები" +"Company" = "კომპანია" +"Compare changes" = "ცვლილებების შედარება" +"Compatibility notes" = "თავსებადობის შენიშვნები" +"Completed" = "დასრულებული" +"Component" = "კომპონენტი" +"Component name" = "კომპონენტის სახელი" +"Component property" = "კომპონენტის თვისება" +"Component set" = "კომპონენტების ნაკრები" +"Component sets" = "კომპონენტების ნაკრებები" +"Components" = "კომპონენტები" +"Conditional" = "პირობითი" +"Confetti" = "კონფეტი" +"Conflicts" = "კონფლიქტები" +"Connect to code" = "კოდთან დაკავშირება" +"Connected" = "დაკავშირებულია" +"Connected to code: {from}" = "დაკავშირებულია კოდთან: {from}" +"Connection lost, reconnecting (attempt {attempt})…" = "კავშირი გაწყდა, ხელახლა დაკავშირება (მცდელობა {attempt})…" +"Constrain proportions" = "პროპორციების შენარჩუნება" +"Constraints" = "შეზღუდვები" +"Content" = "შიგთავსი" +"Contents hidden" = "შიგთავსი დამალულია" +"Contents only" = "მხოლოდ შიგთავსი" +"Contextual alternates" = "კონტექსტური ალტერნატივები" +"Contrast" = "კონტრასტი" +"Contributors" = "მონაწილეები" +"Contributors: {people} · Commits: {commits}" = "მონაწილეები: {people} · კომიტები: {commits}" +"Controller" = "კონტროლერი" +"Cool" = "ცივი" +"Copied as CSS" = "დაკოპირდა როგორც CSS" +"Copied as PNG" = "დაკოპირდა როგორც PNG" +"Copied as SVG" = "დაკოპირდა როგორც SVG" +"Copied as text" = "დაკოპირდა როგორც ტექსტი" +"Copied {value}" = "დაკოპირდა: {value}" +"Copy" = "კოპირება" +"Copy as" = "კოპირება როგორც" +"Copy as CSS" = "კოპირება როგორც CSS" +"Copy as PNG" = "კოპირება როგორც PNG" +"Copy as SVG" = "კოპირება როგორც SVG" +"Copy code" = "კოდის კოპირება" +"Copy link" = "ბმულის კოპირება" +"Copy the {label}" = "კოპირება: {label}" +"Copy {value}" = "კოპირება: {value}" +"Corner radius" = "კუთხის რადიუსი" +"Corner smoothing" = "კუთხეების დარბილება" +"Couldn't download the variables" = "ცვლადების ჩამოტვირთვა ვერ მოხერხდა" +"Couldn't reach {server}: showing what this computer remembers." = "{server}-თან დაკავშირება ვერ მოხერხდა: ნაჩვენებია ის, რაც ამ კომპიუტერს ახსოვს." +"Couldn't save automatically: {error}" = "ავტომატურად შენახვა ვერ მოხერხდა: {error}" +"Count" = "რაოდენობა" +"Create" = "შექმნა" +"Create a variable collection first" = "ჯერ შექმენით ცვლადების კოლექცია" +"Create a variable first" = "ჯერ შექმენით ცვლადი" +"Create alias" = "ალიასის შექმნა" +"Create branch" = "განშტოების შექმნა" +"Create collection" = "კოლექციის შექმნა" +"Create component" = "კომპონენტის შექმნა" +"Create component property" = "კომპონენტის თვისების შექმნა" +"Create components, styles or variables to publish them as a library." = "შექმენით კომპონენტები, სტილები ან ცვლადები, რომ ბიბლიოთეკად გამოაქვეყნოთ." +"Create property…" = "თვისების შექმნა…" +"Create style" = "სტილის შექმნა" +"Create style from the selected text" = "სტილის შექმნა მონიშნული ტექსტიდან" +"Created" = "შექმნილია" +"Crop" = "ჩამოჭრა" +"Crop marks" = "ჭრის ნიშნები" +"Ctrl+arrows" = "Ctrl+ისრები" +"Ctrl+click" = "Ctrl+დაწკაპუნება" +"Ctrl+scroll" = "Ctrl+გადახვევა" +"Current name" = "მიმდინარე სახელი" +"Current version" = "მიმდინარე ვერსია" +"Cursor chat" = "კურსორის ჩატი" +"Custom" = "მორგებული" +"Custom size" = "მორგებული ზომა" +"Cut" = "ამოჭრა" +"Dark" = "ბნელი" +"Darken" = "ჩამუქება" +"Dash" = "ტირე" +"Dashboard" = "სამართავი დაფა" +"Date created" = "შექმნის თარიღი" +"Dec" = "დეკ" +"Decoration style" = "გაფორმების სტილი" +"Decoration" = "გაფორმება" +"Deep select" = "ღრმა მონიშვნა" +"Default" = "ნაგულისხმევი" +"Default content" = "ნაგულისხმევი შიგთავსი" +"Default value (the top-left variant)" = "ნაგულისხმევი მნიშვნელობა (ზედა მარცხენა ვარიანტი)" +"Delay" = "დაყოვნება" +"Delete" = "წაშლა" +"Delete category (its annotations stay)" = "კატეგორიის წაშლა (მისი ანოტაციები რჩება)" +"Delete collection" = "კოლექციის წაშლა" +"Delete group" = "ჯგუფის წაშლა" +"Delete mode" = "რეჟიმის წაშლა" +"Delete page" = "გვერდის წაშლა" +"Delete preset" = "პრესეტის წაშლა" +"Delete property" = "თვისების წაშლა" +"Delete the style (layers keep their values)" = "სტილის წაშლა (შრეები ინარჩუნებს მნიშვნელობებს)" +"Delete thread" = "კომენტარისა და პასუხების წაშლა" +"Delete variable" = "ცვლადის წაშლა" +"Delete “{name}”? This can't be undone." = "წაიშალოს „{name}“? ამის დაბრუნება შეუძლებელია." +"Density" = "სიმკვრივე" +"Depth" = "სიღრმე" +"Describe how and when to use this component" = "აღწერეთ, როგორ და როდის გამოიყენება ეს კომპონენტი" +"Describe what changed" = "აღწერეთ, რა შეიცვალა" +"Description" = "აღწერა" +"Description (optional)" = "აღწერა (არასავალდებულო)" +"Design" = "დიზაინი" +"Design panel" = "დიზაინის პანელი" +"Design system starter" = "დიზაინ-სისტემის საწყისი ნაკრები" +"Desktop" = "დესკტოპი" +"Destination" = "დანიშნულება" +"Detach alias" = "ალიასის გამოყოფა" +"Detach instance" = "ინსტანსის გამოყოფა" +"Detach property" = "თვისების გამოყოფა" +"Detach style" = "სტილის გამოყოფა" +"Detach styles" = "სტილების გამოყოფა" +"Detach variable" = "ცვლადის გამოყოფა" +"Details" = "დეტალები" +"Dev Mode" = "დეველოპერის რეჟიმი" +"Dev resources" = "დეველოპერის რესურსები" +"Development" = "დეველოპმენტი" +"Device" = "მოწყობილობა" +"Device at 100%" = "მოწყობილობა 100%-ზე" +"Diamond" = "ბრილიანტი" +"Difference" = "სხვაობა" +"Dimensions" = "ზომები" +"Direction" = "მიმართულება" +"Disconnect from code" = "კოდთან კავშირის გაწყვეტა" +"Dispersion" = "დისპერსია" +"Display name" = "საჩვენებელი სახელი" +"Dissolve" = "გადადნობა" +"Distribute and tidy" = "განაწილება და მოწესრიგება" +"Distribute horizontal spacing" = "ჰორიზონტალური შუალედების განაწილება" +"Distribute vertical spacing" = "ვერტიკალური შუალედების განაწილება" +"Divider" = "გამყოფი" +"Don't Save" = "არ შეინახო" +"Don't truncate" = "შეკვეცის გარეშე" +"Done" = "მზადაა" +"Done editing" = "რედაქტირების დასრულება" +"Dots" = "წერტილები" +"Dotted" = "პუნქტირი" +"Down" = "ქვემოთ" +"Drafts" = "მონახაზები" +"Drag the stroke to add a width point" = "გადაათრიეთ კონტური სიგანის წერტილის დასამატებლად" +"Draw" = "ხატვა" +"Draw a frame" = "დახატეთ ჩარჩო" +"Drawing tools" = "ხატვის ხელსაწყოები" +"Drop a .figcraft, .fig, .json, .svg or image file." = "ჩააგდეთ .figcraft, .fig, .json, .svg ან გამოსახულების ფაილი." +"Drop to import" = "ჩააგდეთ იმპორტისთვის" +"Drop to open" = "ჩააგდეთ გასახსნელად" +"Drop shadow" = "ჩრდილი" +"Dual-licensed MIT OR Apache-2.0. Made by the ArtCraft team and community." = "ორმაგი ლიცენზია: MIT OR Apache-2.0. შექმნილია ArtCraft-ის გუნდისა და საზოგადოების მიერ." +"Duo" = "დუო" +"Duplicate" = "დუბლირება" +"Duplicate by dragging" = "დუბლირება გადათრევით" +"Duplicate mode" = "რეჟიმის დუბლირება" +"Duplicate page" = "გვერდის დუბლირება" +"Edit" = "რედაქტირება" +"Edit as an expression" = "ექსპრესიად რედაქტირება" +"Edit colour" = "ფერის რედაქტირება" +"Edit comment" = "კომენტარის რედაქტირება" +"Edit dev resource" = "დეველოპერის რესურსის რედაქტირება" +"Edit link" = "რედაქტირების ბმული" +"Edit link copied" = "რედაქტირების ბმული დაკოპირდა" +"Edit style" = "სტილის რედაქტირება" +"Edit variable" = "ცვლადის რედაქტირება" +"Edit with the builder" = "კონსტრუქტორით რედაქტირება" +"Edited {when}" = "რედაქტირებულია {when}" +"Effect settings" = "ეფექტის პარამეტრები" +"Effect styles" = "ეფექტის სტილები" +"Effects" = "ეფექტები" +"Elbow" = "კუთხოვანი" +"Ellipse" = "ელიფსი" +"Ellipses" = "ელიფსები" +"Else" = "სხვა შემთხვევაში" +"Else if" = "სხვა შემთხვევაში, თუ" +"Embedded profile" = "ჩაშენებული პროფილი" +"Empty" = "ცარიელი" +"End" = "ბოლო" +"End point" = "საბოლოო წერტილი" +"Ends the session; everyone keeps their copy" = "ასრულებს სესიას; ყველას თავისი ასლი რჩება" +"Enter" = "Enter" +"Enter the address of a figcraft-server and press Browse to see the plugins it offers." = "შეიყვანეთ figcraft-server-ის მისამართი და დააჭირეთ „დათვალიერებას“, რომ ნახოთ მის მიერ შემოთავაზებული პლაგინები." +"Enter to save · Esc to cancel" = "Enter — შენახვა · Esc — გაუქმება" +"Eraser" = "საშლელი" +"Esc" = "Esc" +"Essential" = "ძირითადი" +"Every conflict has a choice." = "ყველა კონფლიქტს არჩევანი აქვს." +"Every top-level frame already starts a flow" = "ყველა ზედა დონის ჩარჩო უკვე იწყებს ნაკადს" +"Exact opacity (4 then 5: 45%)" = "ზუსტი გაუმჭვირვალობა (4, შემდეგ 5: 45%)" +"Exclude" = "გამორიცხვა" +"Excluded" = "გამორიცხული" +"Excluded from layout" = "განლაგებიდან გამორიცხული" +"Excludes" = "გამორიცხვები" +"Exclusion" = "გამორიცხვა" +"Exit focus view (Esc)" = "ფოკუსის ხედიდან გასვლა (Esc)" +"Expand" = "გაშლა" +"Export" = "ექსპორტირება" +"Export all" = "ყველაფრის ექსპორტირება" +"Export frames and layers as PNG, JPG, SVG or PDF" = "ჩარჩოებისა და შრეების ექსპორტირება PNG, JPG, SVG ან PDF ფორმატში" +"Export page as PNG" = "გვერდის PNG ფაილად ექსპორტირება" +"Export settings" = "ექსპორტის პარამეტრები" +"Export variables" = "ცვლადების ექსპორტირება" +"Export {name}" = "{name}-ის ექსპორტირება" +"Export {n} layers" = { one = "შრის ექსპორტირება: {n}", other = "შრის ექსპორტირება: {n}" } +"Exported {file}" = "ექსპორტირებულია: {file}" +"Exported {name}" = "ექსპორტირებულია: {name}" +"Exported {name}; {n} kinds of content were left out" = { one = "ექსპორტირებულია: {name}; გამოტოვებული შიგთავსის სახეობა: {n}", other = "ექსპორტირებულია: {name}; გამოტოვებული შიგთავსის სახეობა: {n}" } +"Exported {n} files" = { one = "ექსპორტირებულია ფაილი: {n}", other = "ექსპორტირებულია ფაილი: {n}" } +"Export…" = "ექსპორტირება…" +"Expose properties to instances" = "თვისებების ჩვენება ინსტანსებში" +"Exposure" = "ექსპოზიცია" +"Expression" = "ექსპრესია" +"Extend collection" = "კოლექციის გაფართოება" +"Extends \"{name}\": overridden values are marked" = "აფართოებს „{name}“-ს: შეცვლილი მნიშვნელობები მონიშნულია" +"Eye" = "თვალი" +"F" = "F" +"Fade" = "მილევა" +"False" = "მცდარი" +"Feb" = "თებ" +"Fetching from the server…" = "სერვერიდან მიღება…" +"FigCraft couldn't open its window: {error}" = "FigCraft-მა ფანჯრის გახსნა ვერ შეძლო: {error}" +"FigCraft doesn't support every feature of these files yet. The notes list what was left out or changed in the imported copy; the original file is unchanged." = "FigCraft ჯერ ამ ფაილების ყველა შესაძლებლობას არ უჭერს მხარს. შენიშვნებში ჩამოთვლილია, რა გამოტოვდა ან შეიცვალა იმპორტირებულ ასლში; ორიგინალი ფაილი უცვლელია." +"File" = "ფაილი" +"File name" = "ფაილის სახელი" +"File not found" = "ფაილი ვერ მოიძებნა" +"File published" = "ფაილი გამოქვეყნდა" +"Files aren't end-to-end encrypted: the server's operator can read them." = "ფაილები ბოლომდე არ არის დაშიფრული: სერვერის ოპერატორს შეუძლია მათი წაკითხვა." +"Files nobody opens for {n} days are deleted." = { one = "ფაილები, რომლებსაც არავინ ხსნის, იშლება. დღე: {n}.", other = "ფაილები, რომლებსაც არავინ ხსნის, იშლება. დღე: {n}." } +"Fill" = "შევსება" +"Fill (fr)" = "შევსება (fr)" +"Fill container" = "კონტეინერის შევსება" +"Fill window" = "ფანჯრის შევსება" +"Fills" = "შევსებები" +"Fill|sizing" = "შევსება" +"Filter and sort" = "ფილტრი და სორტირება" +"Find" = "ძებნა" +"Find and replace" = "ძებნა და ჩანაცვლება" +"Find layers" = "შრეების ძებნა" +"First" = "პირველი" +"First commit" = "პირველი კომიტი" +"First on top" = "პირველი ზემოთ" +"Fit" = "მორგება" +"Fit device to window" = "მოწყობილობის ფანჯარაში ჩატევა" +"Fit to contents" = "შიგთავსზე მორგება" +"Fit to window" = "ფანჯარაში ჩატევა" +"Fit width" = "სიგანეში ჩატევა" +"Fixed" = "ფიქსირებული" +"Fixed gap" = "ფიქსირებული შუალედი" +"Fixed size" = "ფიქსირებული ზომა" +"Flatten" = "გაბრტყელება" +"Flip" = "არეკვლა" +"Flip horizontal" = "ჰორიზონტალურად არეკვლა" +"Flip text to the other side" = "ტექსტის მეორე მხარეს გადატანა" +"Flip vertical" = "ვერტიკალურად არეკვლა" +"Flip width profile" = "სიგანის პროფილის არეკვლა" +"Flow starting point" = "ნაკადის საწყისი წერტილი" +"Flow starting points" = "ნაკადის საწყისი წერტილები" +"Flows" = "ნაკადები" +"Focus view" = "ფოკუსის ხედი" +"Fold autosaves" = "ავტოშენახვების ჩაკეცვა" +"Folder: click to collapse" = "საქაღალდე: დაწკაპეთ ჩასაკეცად" +"Folder: click to expand" = "საქაღალდე: დაწკაპეთ გასაშლელად" +"Follow" = "მიყოლა" +"Follow {name}" = "{name}-ის მიყოლა" +"Following" = "მიყოლა" +"Following {name} · {key} to stop" = "მიჰყვებით: {name} · შესაწყვეტად {key}" +"Font" = "შრიფტი" +"Font not installed" = "შრიფტი არ არის დაინსტალირებული" +"Font size" = "შრიფტის ზომა" +"Font style" = "შრიფტის სტილი" +"Forget on this computer" = "ამ კომპიუტერზე დავიწყება" +"Four screens of a trail app with components and a prototype flow" = "ლაშქრობის აპის ოთხი ეკრანი კომპონენტებითა და პროტოტიპის ნაკადით" +"Fractions" = "წილადები" +"Frame" = "ჩარჩო" +"Frame selection" = "მონიშნულის ჩარჩოში მოქცევა" +"Frame tools" = "ჩარჩოს ხელსაწყოები" +"Frames" = "ჩარჩოები" +"From the library \"{name}\": read-only" = "ბიბლიოთეკიდან „{name}“: მხოლოდ წასაკითხად" +"From {name}" = "წყარო: {name}" +"From {server}" = "წყარო: {server}" +"Frost" = "ჭირხლი" +"Gap" = "შუალედი" +"Gap between columns" = "სვეტებს შორის შუალედი" +"Gap between items" = "ელემენტებს შორის შუალედი" +"Gap between rows" = "სტრიქონებს შორის შუალედი" +"Getting started" = "პირველი ნაბიჯები" +"Give the address of a server open to the public, or a server's address with its key (`figcraft-server key` prints it)." = "მიუთითეთ საჯარო სერვერის მისამართი ან სერვერის მისამართი გასაღებთან ერთად (`figcraft-server key` გამოიტანს მას)." +"Glass" = "მინა" +"Go to main component" = "მთავარ კომპონენტზე გადასვლა" +"Go to {name}" = "{name}-ზე გადასვლა" +"Grab bag" = "სხვადასხვა" +"Gradient" = "გრადიენტი" +"Gray" = "ნაცრისფერი" +"Green" = "მწვანე" +"Greyscale components and two example screens to sketch a flow with" = "ნაცრისფერი კომპონენტები და ორი სანიმუშო ეკრანი ნაკადის ჩანახატისთვის" +"Grid" = "ბადე" +"Grid ({size})" = "ბადე ({size})" +"Grid styles" = "ბადის სტილები" +"Grid {size}" = "ბადე {size}" +"Group" = "ჯგუფი" +"Group selection" = "ჯგუფის მონიშვნა" +"Groups" = "ჯგუფები" +"Gutter" = "შუალედი" +"Hand tool" = "ხელის ხელსაწყო" +"Hanging list" = "გამოწეული სია" +"Hanging punctuation" = "გამოწეული პუნქტუაცია" +"Hard light" = "მკვეთრი სინათლე" +"Heart" = "გული" +"Height" = "სიმაღლე" +"Help" = "დახმარება" +"Help and resources" = "დახმარება და რესურსები" +"Help page" = "დახმარების გვერდი" +"Here: {names}" = "აქ: {names}" +"Hidden" = "დამალული" +"Hide contents" = "შიგთავსის დამალვა" +"Hide from publishing" = "გამოქვეყნებისას დამალვა" +"Hide section contents" = "სექციის შიგთავსის დამალვა" +"Hide these tips (Getting started in the Help menu shows them again)" = "ამ რჩევების დამალვა („პირველი ნაბიჯები“ დახმარების მენიუში ისევ აჩვენებს მათ)" +"Hide when publishing" = "გამოქვეყნებისას დამალვა" +"Highlight layers on hover" = "შრეების გამოკვეთა მაჩვენებლის მიტანისას" +"Highlighter" = "ხაზგასმის მარკერი" +"Highlights" = "ღია ტონები" +"Hold Alt" = "Alt-ის დაჭერა" +"Hold Alt+Z" = "Alt+Z-ის დაჭერა" +"Hold Ctrl" = "Ctrl-ის დაჭერა" +"Hold Z" = "Z-ის დაჭერა" +"Hold ⌃ or ⌘" = "⌃-ის ან ⌘-ის დაჭერა" +"Hold ⌥" = "⌥-ის დაჭერა" +"Hold ⌥Z" = "⌥Z-ის დაჭერა" +"Home" = "საწყისი ეკრანი" +"Horizontal" = "ჰორიზონტალური" +"Horizontal align" = "ჰორიზონტალური სწორება" +"Horizontal hex" = "ჰორიზონტალური ექვსკუთხედი" +"Horizontal padding" = "ჰორიზონტალური შიდა დაშორება" +"Horizontal spacing" = "ჰორიზონტალური დაშორება" +"Hosted by {operator}" = "მასპინძელი: {operator}" +"Hover a layer to see its properties" = "მიიტანეთ მაჩვენებელი შრეზე მისი თვისებების სანახავად" +"Hover other layers to measure the distance" = "მიიტანეთ მაჩვენებელი სხვა შრეებზე მანძილის გასაზომად" +"Hue" = "ფერის ტონი" +"Hug" = "შიგთავსზე მორგება" +"Hug contents" = "შიგთავსზე მორგება" +"Icon components on a 24 px grid with keylines and a size preview" = "ხატულების კომპონენტები 24 px ბადეზე საორიენტაციო ხაზებითა და ზომების გადახედვით" +"Icon set starter" = "ხატულების ნაკრების საწყისი" +"If it keeps happening, please report it at {link} with this message." = "თუ ეს განმეორდება, გთხოვთ, ეს შეტყობინება გამოგვიგზავნეთ აქ: {link}" +"If" = "თუ" +"If {condition}" = "თუ {condition}" +"Ignore auto layout (absolute position)" = "ავტოგანლაგების იგნორირება (აბსოლუტური პოზიცია)" +"Image" = "გამოსახულება" +"Images" = "გამოსახულებები" +"Import" = "იმპორტი" +"Import path" = "იმპორტის გზა" +"Import plugin from manifest" = "პლაგინის იმპორტი მანიფესტიდან" +"Import plugin from manifest…" = "პლაგინის იმპორტი მანიფესტიდან…" +"Import report" = "იმპორტის ანგარიში" +"Import variables" = "ცვლადების იმპორტი" +"Imported document" = "იმპორტირებული დოკუმენტი" +"Imported {name}" = "იმპორტირებულია: {name}" +"Imported {n} new variables, updated {updated}" = { one = "იმპორტირებულია ახალი ცვლადი: {n}; განახლდა: {updated}", other = "იმპორტირებულია ახალი ცვლადი: {n}; განახლდა: {updated}" } +"Import…" = "იმპორტი…" +"In an instance, this comes from the main component" = "ინსტანსში ეს მთავარი კომპონენტიდან მოდის" +"In the browser you can join sessions the desktop app hosts and files on a FigCraft server. A page opened over https reaches other computers only through wss:// links (a server behind TLS)." = "ბრაუზერში შეგიძლიათ შეუერთდეთ სესიებს, რომლებსაც სამუშაო მაგიდის აპი მასპინძლობს, და FigCraft-ის სერვერზე არსებულ ფაილებს. https-ით გახსნილი გვერდი სხვა კომპიუტერებს მხოლოდ wss:// ბმულებით აღწევს (TLS-ის მიღმა მყოფი სერვერი)." +"Include bounding box" = "შემომსაზღვრელი ჩარჩოს ჩართვა" +"Include id attribute" = "id ატრიბუტის ჩართვა" +"Included" = "ჩართული" +"Included in layout" = "განლაგებაში ჩართული" +"Individual corners" = "ცალკეული კუთხეები" +"Individual strokes" = "ცალკეული კონტურები" +"Ink" = "მელანი" +"Inner shadow" = "შიდა ჩრდილი" +"Inside" = "შიგნით" +"Inspect" = "ინსპექტირება" +"Inspect the layer under the pointer: size, colours, typography" = "მაჩვენებლის ქვეშ მყოფი შრის ინსპექტირება: ზომა, ფერები, ტიპოგრაფია" +"Install plugin" = "პლაგინის ინსტალაცია" +"Install" = "ინსტალაცია" +"Installed {name}" = "დაინსტალირდა: {name}" +"Installed" = "დაინსტალირებულია" +"Installing…" = "ინსტალაცია…" +"Instance" = "ინსტანსი" +"Instance properties" = "ინსტანსის თვისებები" +"Instance swap" = "ინსტანსის ჩანაცვლება" +"Instance swap (no other component)" = "ინსტანსის ჩანაცვლება (სხვა კომპონენტი არ არის)" +"Instances" = "ინსტანსები" +"Instant" = "მყისიერი" +"Intensity" = "ძალა" +"Interaction" = "ურთიერთქმედება" +"Interactions" = "ურთიერთქმედებები" +"Interface design and prototyping. A free, open-source, clean-room design tool written from scratch in Rust, part of the ArtCraft Crafting Apps." = "ინტერფეისის დიზაინი და პროტოტიპირება. უფასო, ღია კოდის, დამოუკიდებლად (clean-room) შექმნილი დიზაინის ხელსაწყო, ნულიდან დაწერილი Rust-ზე, ArtCraft Crafting Apps-ის ნაწილი." +"Intersect" = "გადაკვეთა" +"Intersects" = "გადაკვეთები" +"Invalid color" = "არასწორი ფერი" +"Invite link" = "მოსაწვევი ბმული" +"Invite link copied" = "მოსაწვევი ბმული დაკოპირდა" +"It asks permission to:" = "ის ითხოვს ნებართვას:" +"Jan" = "იან" +"Join" = "შეერთება" +"Join code copied" = "შეერთების კოდი დაკოპირდა" +"Join code or invite link" = "შეერთების კოდი ან მოსაწვევი ბმული" +"Join live session" = "ცოცხალ სესიასთან შეერთება" +"Join us on Discord" = "შემოგვიერთდით Discord-ზე" +"Join with a code" = "კოდით შეერთება" +"Joined" = "შეერთებულია" +"Joined {file}" = "შეერთებულია: {file}" +"Joining…" = "შეერთება…" +"Join|stroke" = "შეერთება" +"Jul" = "ივლ" +"Jun" = "ივნ" +"Just now" = "ახლახან" +"Justify" = "ორმხრივი სწორება" +"Keep appearance" = "იერსახის შენარჩუნება" +"Keep appearance converts every color value to {profile} so colors look the same. Keep values leaves the numbers as they are, so colors shift." = "„იერსახის შენარჩუნება“ ყველა ფერის მნიშვნელობას {profile}-ში გარდაქმნის, რომ ფერები იგივენაირად გამოჩნდეს. „მნიშვნელობების შენარჩუნება“ რიცხვებს უცვლელად ტოვებს, ამიტომ ფერები იცვლება." +"Keep tool selected after use" = "ხელსაწყოს არჩეულად დატოვება გამოყენების შემდეგ" +"Keep values" = "მნიშვნელობების შენარჩუნება" +"Key/Gamepad" = "კლავიში/გეიმპადი" +"Key/Gamepad {key}" = "კლავიში/გეიმპადი {key}" +"Keyboard shortcuts" = "კლავიატურის მალსახმობები" +"Kits" = "ნაკრებები" +"Landing page" = "ლენდინგი" +"Language" = "ენა" +"Lasso" = "ლასო" +"Last" = "ბოლო" +"Last change: {note}" = "ბოლო ცვლილება: {note}" +"Last commit" = "ბოლო კომიტი" +"Last modified" = "ბოლო ცვლილება" +"Last on top" = "ბოლო ზემოთ" +"Layer blur" = "შრის გაბუნდოვნება" +"Layer name" = "შრის სახელი" +"Layers" = "შრეები" +"Layers in an instance take their position from the main component" = "ინსტანსის შრეები პოზიციას მთავარი კომპონენტიდან იღებს" +"Layout" = "განლაგება" +"Layout grid" = "განლაგების ბადე" +"Layout grid settings" = "განლაგების ბადის პარამეტრები" +"Layout grids" = "განლაგების ბადეები" +"Leave session" = "სესიიდან გასვლა" +"Leaves" = "ფოთლები" +"Left" = "მარცხნივ" +"Left & right" = "მარცხნივ და მარჯვნივ" +"Left side" = "მარცხენა მხარე" +"Left side width" = "მარცხენა მხარის სიგანე" +"Letter case" = "ასოთა რეგისტრი" +"Letter spacing" = "ასოებს შორის დაშორება" +"Libraries" = "ბიბლიოთეკები" +"Libraries in this file" = "ამ ფაილის ბიბლიოთეკები" +"Library updates available" = "ხელმისაწვდომია ბიბლიოთეკის განახლებები" +"Library: click to collapse" = "ბიბლიოთეკა: დაწკაპეთ ჩასაკეცად" +"Library: click to expand" = "ბიბლიოთეკა: დაწკაპეთ გასაშლელად" +"Library: its variables are read-only here; edit them in the library file and publish it" = "ბიბლიოთეკა: მისი ცვლადები აქ მხოლოდ წასაკითხია; შეცვალეთ ისინი ბიბლიოთეკის ფაილში და გამოაქვეყნეთ" +"Light" = "ღია" +"Lighten" = "გაღიავება" +"Light|glass" = "ღია" +"Line" = "ხაზი" +"Line arrow" = "ხაზოვანი ისარი" +"Line delta" = "სტრიქონების სხვაობა" +"Line height" = "სტრიქონის სიმაღლე" +"Linear" = "წრფივი" +"Lines" = "სტრიქონები" +"Lines +/−" = "სტრიქონები +/−" +"Lines added" = "დამატებული სტრიქონები" +"Lines deleted" = "წაშლილი სტრიქონები" +"Link" = "ბმული" +"Link copied" = "ბმული დაკოპირდა" +"Link docs, code or tickets for developers." = "მიაბით დოკუმენტაცია, კოდი ან ტიკეტები დეველოპერებისთვის." +"Link frames on the Prototype tab, then present the flow" = "დააკავშირეთ ჩარჩოები „პროტოტიპის“ ჩანართზე, შემდეგ წარადგინეთ ნაკადი" +"Link this component to its code component: instances then show your real snippet with their values." = "დააკავშირეთ ეს კომპონენტი მის კოდის კომპონენტთან: ინსტანსები მაშინ თქვენს რეალურ ფრაგმენტს აჩვენებს მათი მნიშვნელობებით." +"List spacing" = "სიის დაშორება" +"List style" = "სიის სტილი" +"Live session" = "ცოცხალი სესია" +"Live · {people}" = "ცოცხლად · {people}" +"Local components" = "ლოკალური კომპონენტები" +"Local network" = "ლოკალური ქსელი" +"Local styles" = "ლოკალური სტილები" +"Local variables" = "ლოკალური ცვლადები" +"Lock/Unlock" = "ჩაკეტვა/განბლოკვა" +"Locked" = "ჩაკეტილი" +"Lowercase" = "პატარა ასოები" +"Luminance" = "სიკაშკაშე" +"Luminosity" = "ნათება" +"Main" = "მთავარი" +"Main component deleted" = "მთავარი კომპონენტი წაიშალა" +"Main menu" = "მთავარი მენიუ" +"Make a component" = "შექმენით კომპონენტი" +"Make slot" = "სლოტად გადაქცევა" +"Mar" = "მარ" +"Margin" = "ველი" +"Mark all as read" = "ყველას წაკითხულად მარკირება" +"Mark as resolved" = "გადაწყვეტილად მარკირება" +"Mark as unread" = "წაუკითხავად მარკირება" +"Mark as unresolved" = "გადაუწყვეტლად მარკირება" +"Marker" = "მარკერი" +"Mask" = "ნიღაბი" +"Mass · Stiffness · Damping" = "მასა · სიხისტე · მილევა" +"Match (empty: the whole name)" = "დამთხვევა (ცარიელი: მთელი სახელი)" +"Max H" = "მაქს. H" +"Max W" = "მაქს. W" +"Max lines" = "მაქს. სტრიქონები" +"Maximize" = "გაშლა" +"May" = "მაი" +"Measure to the selection" = "მონიშნულამდე გაზომვა" +"Merge into main" = "მთავარში შერწყმა" +"Merge regions" = "არეების შერწყმა" +"Merge the branch into the main file (one undo step) and archive it" = "განშტოების მთავარ ფაილთან შერწყმა (დაბრუნების ერთი ნაბიჯი) და მისი დაარქივება" +"Merged" = "შერწყმულია" +"Merged PRs" = "გაერთიანებული PR-ები" +"Min H" = "მინ. H" +"Min W" = "მინ. W" +"Minimize" = "ჩაკეცვა" +"Mirror angle" = "კუთხის არეკვლა" +"Mirror angle and length" = "კუთხისა და სიგრძის არეკვლა" +"Mirroring" = "არეკვლა" +"Missing" = "აკლია" +"Missing fonts" = "მიუწვდომელი შრიფტები" +"Missing layer" = "შრე ვერ მოიძებნა" +"Missing style" = "სტილი ვერ მოიძებნა" +"Missing variable" = "ცვლადი ვერ მოიძებნა" +"Miter" = "მახვილი" +"Miter angle" = "მახვილის კუთხე" +"Mixed" = "შერეული" +"Mixed fonts" = "შერეული შრიფტები" +"Mixed interactions: click + to replace them" = "შერეული ურთიერთქმედებები: დაწკაპეთ +, რომ ჩაანაცვლოთ" +"Mobile app" = "მობილური აპი" +"Mode" = "რეჟიმი" +"Model" = "მოდელი" +"Models" = "მოდელები" +"Mono" = "მონო" +"More" = "მეტი" +"More instance actions" = "ინსტანსის სხვა მოქმედებები" +"Mouse down" = "თაგუნას დაჭერა" +"Mouse enter" = "თაგუნას შესვლა" +"Mouse leave" = "მაჩვენებლის გასვლა" +"Mouse up" = "თაგუნას აშვება" +"Move along the toolbar or the layer rows" = "ხელსაწყოთა ზოლში ან შრეების სტრიქონებში გადაადგილება" +"Move" = "გადაადგილება" +"Move column left" = "სვეტის მარცხნივ გადატანა" +"Move column right" = "სვეტის მარჯვნივ გადატანა" +"Move down" = "ჩამოწევა" +"Move in" = "შიგნით გადატანა" +"Move out" = "გარეთ გადატანა" +"Move tools" = "გადაადგილების ხელსაწყოები" +"Move up" = "აწევა" +"Moved to {name}" = "გადატანილია: {name}" +"Multi" = "მრავალი" +"Multiply" = "გამრავლება" +"Muted" = "მკრთალი" +"Name" = "სახელი" +"Name (A–Z)" = "სახელი (ა–ჰ)" +"Named instance" = "დასახელებული ინსტანსი" +"Navigate to" = "გადასვლა" +"Nested instance" = "ჩადგმული ინსტანსი" +"New board" = "ახალი დაფა" +"New design file" = "ახალი დიზაინის ფაილი" +"Newest first" = "ჯერ უახლესი" +"Next or previous control in the focused region" = "ფოკუსირებულ არეში შემდეგი ან წინა მართვის ელემენტი" +"Next frame" = "შემდეგი ჩარჩო" +"Next ready for dev" = "შემდეგი მზად დეველოპერისთვის" +"Next size option" = "ზომის შემდეგი ვარიანტი" +"No assets: no export settings, icons or images. Add export settings in the Design tab." = "რესურსები არ არის: არც ექსპორტის პარამეტრები, არც ხატულები, არც გამოსახულებები. ექსპორტის პარამეტრები დაამატეთ „დიზაინის“ ჩანართზე." +"No boolean variables fit here." = "აქ არცერთი ლოგიკური ცვლადი არ ერგება." +"No boolean variables yet. Create them in Local variables." = "ლოგიკური ცვლადები ჯერ არ არის. შექმენით ისინი ლოკალურ ცვლადებში." +"No branches. A branch is a copy of the file to try changes in, then review and merge." = "განშტოებები არ არის. განშტოება ფაილის ასლია, რომელშიც ცვლილებებს ცდით, შემდეგ კი განიხილავთ და შერწყამთ." +"No category" = "კატეგორიის გარეშე" +"No changes" = "ცვლილებები არ არის" +"No color styles" = "ფერის სტილები არ არის" +"No colour variables fit here." = "აქ არცერთი ფერის ცვლადი არ ერგება." +"No colour variables yet. Create them in Local variables." = "ფერის ცვლადები ჯერ არ არის. შექმენით ისინი ლოკალურ ცვლადებში." +"No comments yet" = "კომენტარები ჯერ არ არის" +"No compatibility warnings reported" = "თავსებადობის გაფრთხილებები არ დაფიქსირებულა" +"No components yet. Select layers and press {keys}." = "კომპონენტები ჯერ არ არის. მონიშნეთ შრეები და დააჭირეთ {keys}." +"No conflicts." = "კონფლიქტები არ არის." +"No contributor data was built into this copy." = "ამ ასლში მონაწილეების მონაცემები არ არის ჩაშენებული." +"No device" = "მოწყობილობის გარეშე" +"No fonts match" = "შესაბამისი შრიფტი არ მოიძებნა" +"No files or templates match your search" = "ძიების შესაბამისი ფაილი ან შაბლონი არ მოიძებნა" +"No join code here (the server is older than join codes, or this address can't be put in one): share the link instead." = "აქ შეერთების კოდი არ არის (სერვერი შეერთების კოდებზე ძველია, ან ეს მისამართი კოდში ვერ ეტევა): ამის ნაცვლად გააზიარეთ ბმული." +"No layer changes to show." = "საჩვენებელი შრეების ცვლილებები არ არის." +"No libraries yet. Add a .figcraft file that was published as a library, or paste a team library's link." = "ბიბლიოთეკები ჯერ არ არის. დაამატეთ ბიბლიოთეკად გამოქვეყნებული .figcraft ფაილი ან ჩასვით გუნდის ბიბლიოთეკის ბმული." +"No list" = "სიის გარეშე" +"No matching comments" = "შესაბამისი კომენტარი არ მოიძებნა" +"No matching components" = "შესაბამისი კომპონენტი არ მოიძებნა" +"No matching layers" = "შესაბამისი შრე არ მოიძებნა" +"No matching styles" = "შესაბამისი სტილი არ მოიძებნა" +"No matching variables" = "შესაბამისი ცვლადი არ მოიძებნა" +"No mirroring" = "არეკვლის გარეშე" +"No model credits were built into this copy." = "ამ ასლში მოდელების მონაცემები არ არის ჩაშენებული." +"No number variables fit here." = "აქ არცერთი რიცხვითი ცვლადი არ ერგება." +"No number variables yet. Create them in Local variables." = "რიცხვითი ცვლადები ჯერ არ არის. შექმენით ისინი ლოკალურ ცვლადებში." +"No other pages" = "სხვა გვერდები არ არის" +"No paints" = "საღებავების გარეშე" +"No plugins on {server}." = "{server}-ზე პლაგინები არ არის." +"No plugins yet. Import a development plugin from its figcraft-plugin.json." = "პლაგინები ჯერ არ არის. დეველოპერის პლაგინის იმპორტი მოახდინეთ მისი figcraft-plugin.json-იდან." +"No preferred components" = "სასურველი კომპონენტების გარეშე" +"No preset" = "პრესეტის გარეშე" +"No saved versions yet. Save a version to mark a point in your work you can come back to." = "შენახული ვერსიები ჯერ არ არის. შეინახეთ ვერსია, რომ მონიშნოთ თქვენი სამუშაოს წერტილი, სადაც შეგიძლიათ დაბრუნდეთ." +"No scrolling" = "გადახვევის გარეშე" +"No shortcuts match" = "შესაბამისი მალსახმობი არ მოიძებნა" +"No status" = "სტატუსის გარეშე" +"No string variables fit here." = "აქ არცერთი სტრიქონული ცვლადი არ ერგება." +"No string variables yet. Create them in Local variables." = "სტრიქონული ცვლადები ჯერ არ არის. შექმენით ისინი ლოკალურ ცვლადებში." +"No styles yet." = "სტილები ჯერ არ არის." +"No styles yet. Click + to create one from the selection." = "სტილები ჯერ არ არის. დაწკაპეთ +, რომ მონიშნულიდან შექმნათ." +"No templates match your search" = "ძიების შესაბამისი შაბლონი არ მოიძებნა" +"No text styles yet. Click + to create one from the selected text." = "ტექსტის სტილები ჯერ არ არის. დაწკაპეთ +, რომ მონიშნული ტექსტიდან შექმნათ." +"No variables fit here." = "აქ არცერთი ცვლადი არ ერგება." +"No variables yet. Create them in Local variables." = "ცვლადები ჯერ არ არის. შექმენით ისინი ლოკალურ ცვლადებში." +"Nobody else is here yet" = "აქ ჯერ სხვა არავინ არის" +"Noise" = "ხმაური" +"None" = "არცერთი" +"None." = "არაფერი." +"Normal" = "ჩვეულებრივი" +"Not bound" = "მიბმული არ არის" +"Not in a live session" = "ცოცხალ სესიაში არ ხართ" +"Not in this font" = "ამ შრიფტში არ არის" +"Not properties, kept as written: {names}" = "არ არის თვისებები, დარჩა დაწერილი სახით: {names}" +"Not read yet" = "ჯერ არ არის წაკითხული" +"Not shared" = "არ არის გაზიარებული" +"Note {n}" = "შენიშვნა {n}" +"Nothing changed since this was marked ready for dev." = "მას შემდეგ, რაც მზად დეველოპერისთვის მოინიშნა, არაფერი შეცვლილა." +"Nothing here yet. Set a frame's status to list it." = "აქ ჯერ არაფერია. ჩარჩოს სიაში გამოსაჩენად მიანიჭეთ სტატუსი." +"Nothing online yet. Use Share on a file to put it online and it is listed here." = "ონლაინ ჯერ არაფერია. ფაილის ონლაინ განსათავსებლად გამოიყენეთ „გაზიარება“ და ის აქ გამოჩნდება." +"Nothing to export" = "საექსპორტო არაფერია" +"Nov" = "ნოე" +"Now" = "ახლა" +"Nudge (Shift: big nudge)" = "მცირედ გადაწევა (Shift: დიდი ბიჯით)" +"Nudge amount" = "მცირედ გადაწევის ბიჯი" +"Number" = "რიცხვი" +"Number ↑" = "რიცხვი ↑" +"Number ↓" = "რიცხვი ↓" +"Numbered list" = "დანომრილი სია" +"Object" = "ობიექტი" +"Oct" = "ოქტ" +"Off" = "გამორთული" +"Offered first when filling the slot (any content is allowed)" = "სლოტის შევსებისას პირველად შემოთავაზებული (ნებისმიერი შიგთავსი დასაშვებია)" +"Offered first when swapping the instance (any component is allowed)" = "ინსტანსის ჩანაცვლებისას პირველად შემოთავაზებული (ნებისმიერი კომპონენტი დასაშვებია)" +"Offset" = "წანაცვლება" +"Oldest first" = "ჯერ უძველესი" +"On click" = "დაწკაპუნებით" +"On drag" = "გადათრევისას" +"On this page" = "ამ გვერდზე" +"One announcement in five common sizes: square, portrait, story, link and banner" = "ერთი განცხადება ხუთ ხშირად გამოყენებულ ზომაში: კვადრატი, ვერტიკალური, სთორი, ბმული და ბანერი" +"Online" = "ონლაინ" +"Online files" = "ონლაინ ფაილები" +"Online sharing is off: files are shared on the local network only." = "ონლაინ გაზიარება გამორთულია: ფაილები მხოლოდ ლოკალურ ქსელში ზიარდება." +"Only the first changed layers are listed." = "ნაჩვენებია მხოლოდ პირველი შეცვლილი შრეები." +"Only the first {n} changed layers are listed." = "ნაჩვენებია მხოლოდ პირველი შეცვლილი შრეები. შრე: {n}." +"Only your threads" = "მხოლოდ თქვენი მიმოწერები" +"Opacity" = "გაუმჭვირვალობა" +"Opacity & blend" = "გაუმჭვირვალობა და შერევა" +"Opacity 10%…90%, 0: 100%" = "გაუმჭვირვალობა 10%…90%, 0: 100%" +"Open" = "გახსნა" +"Open .figcraft, .svg or images" = ".figcraft, .svg ან გამოსახულებების გახსნა" +"Open branch" = "განშტოების გახსნა" +"Open file" = "ფაილის გახსნა" +"Open files" = "გახსნილი ფაილები" +"Open in focus view" = "ფოკუსის ხედში გახსნა" +"Open in new window" = "გახსნა ახალ ფანჯარაში" +"Open invite links in FigCraft" = "მოსაწვევი ბმულების FigCraft-ში გახსნა" +"Open link" = "ბმულის გახსნა" +"Open link {url}" = "ბმულის გახსნა {url}" +"Open overlay" = "გადაფარვის გახსნა" +"Open prototype preview" = "პროტოტიპის წინასწარი ნახვის გახსნა" +"Open variables ({count})" = "ცვლადების გახსნა ({count})" +"Opened as a new document" = "გაიხსნა ახალ დოკუმენტად" +"Opened “{name}” in a new tab" = "„{name}“ გაიხსნა ახალ ჩანართში" +"Opening the file…" = "ფაილის გახსნა…" +"Opening {n} files as new files" = { one = "ახალ ფაილად გახსნა", other = "ახალ ფაილებად გახსნა: {n}" } +"Open|comments" = "გახსნა" +"Options" = "პარამეტრები" +"Orange" = "ნარინჯისფერი" +"Order" = "რიგი" +"Other apps may not open every .fig file exported here." = "სხვა აპებმა შეიძლება ვერ გახსნან აქ ექსპორტირებული ყველა .fig ფაილი." +"Other" = "სხვა" +"Others lose access and keep their copy; the file stays on the server for you" = "სხვები კარგავენ წვდომას და ინარჩუნებენ თავიანთ ასლს; ფაილი სერვერზე თქვენთვის რჩება" +"Others type this under Share ▸ Join with a code" = "სხვები ამას შეიყვანენ აქ: გაზიარება ▸ კოდით შეერთება" +"Outline stroke" = "კონტურის ფიგურად გარდაქმნა" +"Outline text" = "ტექსტის კონტურებად გარდაქმნა" +"Outlines" = "კონტურები" +"Outside" = "გარეთ" +"Overflow" = "გადავსება" +"Overlay" = "ზედდება" +"Overlay position" = "გადაფარვის პოზიცია" +"Overridden here (the collection it extends has another value)" = "აქ შეცვლილია (კოლექციას, რომელსაც აფართოებს, სხვა მნიშვნელობა აქვს)" +"Overrides" = "გადაფარვები" +"Owner token" = "მფლობელის ტოკენი" +"Owner token copied" = "მფლობელის ტოკენი დაკოპირდა" +"PDF print options" = "PDF-ის ბეჭდვის პარამეტრები" +"PDF print settings" = "PDF-ის ბეჭდვის პარამეტრები" +"Padding" = "შიდა დაშორება" +"Page" = "გვერდი" +"Pages" = "გვერდები" +"Pages added to the document" = "გვერდები დაემატა დოკუმენტს" +"Pink" = "ვარდისფერი" +"Placed on the current page" = "განთავსდა მიმდინარე გვერდზე" +"Pages: {pages}" = "გვერდი: {pages}" +"Paint bucket" = "საღებავის ვედრო" +"Pan the canvas" = "ტილოს გადაადგილება" +"Pan with nothing selected (Shift: farther)" = "გადაადგილება, როცა არაფერია მონიშნული (Shift: უფრო შორს)" +"Panels" = "პანელები" +"Paper" = "ქაღალდი" +"Paragraph indent" = "აბზაცის შეწევა" +"Paragraph indent applies to left-aligned text" = "აბზაცის შეწევა ვრცელდება მარცხნივ სწორებულ ტექსტზე" +"Paragraph spacing" = "აბზაცთა ინტერვალი" +"Pass through" = "გატარება" +"Paste a link (https://…)" = "ჩასვით ბმული (https://…)" +"Paste a team library link (a view link)" = "ჩასვით გუნდის ბიბლიოთეკის ბმული (ნახვის ბმული)" +"Paste here" = "აქ ჩასმა" +"Paste to replace" = "ჩასმა ჩასანაცვლებლად" +"Pasted design layers; {n} kinds of content were left out" = { one = "ჩასმულია დიზაინის შრეები; გამოტოვებული შიგთავსის სახეობა: {n}", other = "ჩასმულია დიზაინის შრეები; გამოტოვებული შიგთავსის სახეობა: {n}" } +"Pattern" = "პატერნი" +"Pattern alignment" = "პატერნის სწორება" +"Pattern scale" = "პატერნის მასშტაბი" +"Pattern source" = "პატერნის წყარო" +"Pen" = "კალამი" +"Pencil" = "ფანქარი" +"People in this file" = "ამ ფაილში მყოფი ადამიანები" +"People join from anywhere with a code. The file is kept on {server} while you share it." = "ადამიანები ნებისმიერი ადგილიდან კოდით უერთდებიან. სანამ აზიარებთ, ფაილი {server}-ზე ინახება." +"People on your network join with a code. Nothing goes through a server." = "თქვენი ქსელის ადამიანები კოდით უერთდებიან. სერვერზე არაფერი გადის." +"Phone" = "ტელეფონი" +"Pick color" = "ფერის არჩევა" +"Pick color (I)" = "ფერის აღება (I)" +"Pick one font family first" = "ჯერ აირჩიეთ ერთი შრიფტის ოჯახი" +"Pick the Frame tool, then drag on the canvas or choose a device size" = "აირჩიეთ ჩარჩოს ხელსაწყო, შემდეგ გადაათრიეთ ტილოზე ან აირჩიეთ მოწყობილობის ზომა" +"Pin a property" = "თვისების მიმაგრება" +"Pinch" = "მოჭყლეტა" +"Pixel grid" = "პიქსელური ბადე" +"Pixel preview 1x" = "პიქსელების წინასწარი ნახვა 1x" +"Pixel preview 2x" = "პიქსელების წინასწარი ნახვა 2x" +"Place a shape or frame (its tool chosen)" = "ფიგურის ან ჩარჩოს განთავსება (არჩეული ხელსაწყოთი)" +"Place automatically" = "ავტომატურად განთავსება" +"Place image" = "გამოსახულების განთავსება" +"Plugin" = "პლაგინი" +"Plugins run in FigCraft's sandbox, without files, network or clock. Install only plugins you trust." = "პლაგინები FigCraft-ის იზოლირებულ გარემოში მუშაობს, ფაილების, ქსელისა და საათის გარეშე. დააინსტალირეთ მხოლოდ სანდო პლაგინები." +"Plugins" = "პლაგინები" +"Plugins & widgets" = "პლაგინები და ვიჯეტები" +"Plus darker" = "პლუს მუქი" +"Plus lighter" = "პლუს ღია" +"Plus one" = "პლუს ერთი" +"Point" = "წერტილი" +"Points" = "წერტილები" +"Polygon" = "მრავალკუთხედი" +"Polygons" = "მრავალკუთხედები" +"Port" = "პორტი" +"Position" = "პოზიცია" +"Post" = "გაგზავნა" +"Preferences" = "პარამეტრები" +"Preferred" = "სასურველი" +"Present" = "წარდგენა" +"Present or preview" = "წარდგენა ან წინასწარი ნახვა" +"Present this flow" = "ამ ნაკადის წარდგენა" +"Presentation" = "პრეზენტაცია" +"Presentation slides" = "პრეზენტაციის სლაიდები" +"Presenting: comments" = "წარდგენა: კომენტარები" +"Presenting: cycle the size options" = "წარდგენა: ზომის ვარიანტების მონაცვლეობა" +"Presenting: full screen" = "წარდგენა: სრული ეკრანი" +"Presenting: next frame" = "წარდგენა: შემდეგი ჩარჩო" +"Presenting: previous frame" = "წარდგენა: წინა ჩარჩო" +"Presenting: restart" = "წარდგენა: თავიდან დაწყება" +"Preserve scroll position" = "გადახვევის პოზიციის შენარჩუნება" +"Presets" = "პრესეტები" +"Press a key…" = "დააჭირეთ კლავიშს…" +"Playground" = "საცდელი სივრცე" +"Preview" = "წინასწარი ნახვა" +"Preview this flow" = "ამ ნაკადის წინასწარი ნახვა" +"Preview this frame" = "ამ ჩარჩოს წინასწარი ნახვა" +"Previous frame" = "წინა ჩარჩო" +"Previous ready for dev" = "წინა მზად დეველოპერისთვის" +"Progressive" = "პროგრესული" +"Properties" = "თვისებები" +"Prototype" = "პროტოტიპი" +"Prototype preview" = "პროტოტიპის წინასწარი ნახვა" +"Prototype settings" = "პროტოტიპის პარამეტრები" +"Publish" = "გამოქვეყნება" +"Publish changes" = "ცვლილებების გამოქვეყნება" +"Publish to another server…" = "სხვა სერვერზე გამოქვეყნება…" +"Publish to server" = "სერვერზე გამოქვეყნება" +"Published version {v}" = "გამოქვეყნდა ვერსია {v}" +"Published version {v} to the server" = "ვერსია {v} გამოქვეყნდა სერვერზე" +"Published version {v}: save the file so other files can use it" = "გამოქვეყნდა ვერსია {v}: შეინახეთ ფაილი, რომ სხვა ფაილებმა შეძლონ მისი გამოყენება" +"Published · {people}" = "გამოქვეყნებულია · {people}" +"Publishing to a server needs the desktop app; in the browser you can join a server's files" = "სერვერზე გამოსაქვეყნებლად სამუშაო მაგიდის აპია საჭირო; ბრაუზერში შეგიძლიათ სერვერის ფაილებს შეუერთდეთ" +"Publishing with the server's key." = "ქვეყნდება სერვერის გასაღებით." +"Publishing…" = "გამოქვეყნება…" +"Push" = "ბიძგი" +"Push changes to main component" = "ცვლილებების მთავარ კომპონენტში გადაგზავნა" +"Put text on path" = "ტექსტის ტრაექტორიაზე განთავსება" +"Question" = "კითხვა" +"R" = "R" +"Radial" = "რადიალური" +"Radius" = "რადიუსი" +"Ratio" = "თანაფარდობა" +"Reading the library…" = "ბიბლიოთეკის წაკითხვა…" +"Ready for dev" = "მზადაა დეველოპერისთვის" +"Real name" = "ნამდვილი სახელი" +"Recent" = "ბოლოდროინდელი" +"Recent files" = "ბოლოს გახსნილი ფაილები" +"Recents" = "ბოლოდროინდელი" +"Recovered {n} unsaved files after the app quit unexpectedly: save them to keep them" = { one = "აპის მოულოდნელი დახურვის შემდეგ აღდგა შეუნახავი ფაილი: შესანარჩუნებლად შეინახეთ", other = "აპის მოულოდნელი დახურვის შემდეგ აღდგა შეუნახავი ფაილი: {n}. შესანარჩუნებლად შეინახეთ" } +"Rectangle" = "მართკუთხედი" +"Rectangles" = "მართკუთხედები" +"Red" = "წითელი" +"Reduce motion" = "მოძრაობის შემცირება" +"Refraction" = "გარდატეხა" +"Regular expression" = "რეგულარული გამოსახულება" +"Remove" = "წაშლა" +"Remove all" = "ყველას წაშლა" +"Remove annotation" = "ანოტაციის წაშლა" +"Remove auto layout" = "ავტოგანლაგების მოხსნა" +"Remove dev resource" = "დეველოპერის რესურსის წაშლა" +"Remove export setting" = "ექსპორტის პარამეტრის წაშლა" +"Remove from recents" = "ბოლოდროინდელებიდან ამოშლა" +"Remove from server" = "სერვერიდან წაშლა" +"Remove from server…" = "სერვერიდან წაშლა…" +"Remove guide" = "დამხმარე ხაზის მოცილება" +"Remove interaction" = "ურთიერთქმედების წაშლა" +"Remove library from this file" = "ბიბლიოთეკის ამ ფაილიდან ამოშლა" +"Remove min and max" = "მინ. და მაქს. მოხსნა" +"Remove min and max height" = "მინ. და მაქს. სიმაღლის მოხსნა" +"Remove min and max width" = "მინ. და მაქს. სიგანის მოხსნა" +"Remove regions" = "არეების წაშლა" +"Remove slot" = "სლოტის მოხსნა" +"Remove starting point" = "საწყისი წერტილის წაშლა" +"Remove stop" = "გადასვლის წერტილის წაშლა" +"Remove text from path" = "ტექსტის ტრაექტორიიდან მოხსნა" +"Remove the else and its actions" = "„სხვა შემთხვევაში“ და მისი მოქმედებების წაშლა" +"Remove this condition and its actions" = "ამ პირობისა და მისი მოქმედებების წაშლა" +"Remove this file from the server? It can't be shared or opened from there again. Your copy stays open here." = "წაიშალოს ეს ფაილი სერვერიდან? იქიდან მისი გაზიარება ან გახსნა აღარ იქნება შესაძლებელი. თქვენი ასლი აქ ღია რჩება." +"Remove {name} from session" = "სესიიდან მოცილება: {name}" +"Remove {name} from {server}?" = "წაიშალოს {name} {server}-იდან?" +"Removed" = "წაშლილია" +"Removed from the server" = "წაიშალა სერვერიდან" +"Removed since it was marked" = "წაიშალა მონიშვნის შემდეგ" +"Rename" = "სახელის შეცვლა" +"Rename duplicated layers" = "დუბლირებული შრეების სახელის შეცვლა" +"Rename folder" = "საქაღალდის სახელის შეცვლა" +"Rename group" = "ჯგუფის სახელის შეცვლა" +"Rename layers" = "შრეების სახელის შეცვლა" +"Rename mode" = "რეჟიმის სახელის შეცვლა" +"Rename on the server" = "სახელის შეცვლა სერვერზე" +"Rename on the server…" = "სახელის შეცვლა სერვერზე…" +"Rename page" = "გვერდის სახელის შეცვლა" +"Rename to" = "ახალი სახელი" +"Replace all" = "ყველას ჩანაცვლება" +"Replace mixed export settings" = "შერეული ექსპორტის პარამეტრების ჩანაცვლება" +"Replace with" = "ჩანაცვლება:" +"Replace with a new interaction" = "ახალი ურთიერთქმედებით ჩანაცვლება" +"Reply" = "პასუხი" +"Report" = "საჩივარი" +"Report this file…" = "ამ ფაილზე საჩივარი…" +"Reset" = "ჩამოყრა" +"Reset adjustments" = "კორექციების ჩამოყრა" +"Reset all changes" = "ყველა ცვლილების ჩამოყრა" +"Reset all overrides on this layer" = "ამ შრის ყველა გადაფარვის ჩამოყრა" +"Reset overrides" = "გადაფარვების ჩამოყრა" +"Reset slot" = "სლოტის ჩამოყრა" +"Reset this override" = "ამ გადაფარვის ჩამოყრა" +"Reset to style" = "სტილზე ჩამოყრა" +"Reset to the inherited value" = "მემკვიდრეობით მიღებულ მნიშვნელობაზე ჩამოყრა" +"Resize by the nudge amount" = "ზომის შეცვლა მცირედ გადაწევის ბიჯით" +"Resize frames to the device's screen (or the window without a device): their constraints and auto layout respond" = "ჩარჩოების ზომის მორგება მოწყობილობის ეკრანზე (ან ფანჯარაზე, თუ მოწყობილობა არ არის): მათი შეზღუდვები და ავტოგანლაგება რეაგირებს" +"Resize to fit" = "ზომის მორგება" +"Resizing" = "ზომის შეცვლა" +"Resolve" = "გადაწყვეტა" +"Resolve change" = "ცვლილების გადაწყვეტა" +"Resolved" = "გადაწყვეტილია" +"Resources" = "რესურსები" +"Responsive" = "ადაპტური" +"Restart" = "გადატვირთვა" +"Restore" = "აღდგენა" +"Restore main component" = "მთავარი კომპონენტის აღდგენა" +"Restore this version" = "ამ ვერსიის აღდგენა" +"Restored “{name}”" = "„{name}“ აღდგა" +"Reverse the order" = "რიგის შებრუნება" +"Reversed triangle" = "შებრუნებული სამკუთხედი" +"Review and merge" = "განხილვა და შერწყმა" +"Ribbon" = "ლენტი" +"Right" = "მარჯვნივ" +"Right side" = "მარჯვენა მხარე" +"Right side width" = "მარჯვენა მხარის სიგანე" +"Rotate 90°" = "90°-ით შემობრუნება" +"Rotate 90° right" = "90°-ით მარჯვნივ შემობრუნება" +"Rotate to landscape" = "ჰორიზონტალურად შემობრუნება" +"Rotate to portrait" = "ვერტიკალურად შემობრუნება" +"Rotation" = "შემობრუნება" +"Rotation jitter" = "შემობრუნების ცვალებადობა" +"Round" = "მომრგვალებული" +"Rounded" = "მომრგვალებული" +"Rounded rectangle" = "მომრგვალებული მართკუთხედი" +"Row" = "სტრიქონი" +"Row gap" = "სტრიქონებს შორის შუალედი" +"Row sizes" = "სტრიქონების ზომები" +"Row span" = "სტრიქონების გაერთიანება" +"Rows" = "სტრიქონები" +"Rows {count}" = "სტრიქონები {count}" +"Rulers" = "სახაზავები" +"Run" = "გაშვება" +"Run {label}" = "გაშვება: {label}" +"Save files automatically" = "ფაილების ავტომატურად შენახვა" +"Search templates" = "შაბლონების ძიება" +"See all templates" = "ყველა შაბლონის ნახვა" +"Select layers and they space themselves as their content changes" = "მონიშნეთ შრეები და შიგთავსის ცვლილებისას ისინი ავტომატურად დაშორდება ერთმანეთს" +"SHA-256: {sha}…" = "SHA-256: {sha}…" +"Same as layer" = "შრის მსგავსად" +"Saturation" = "გაჯერება" +"Save" = "შენახვა" +"Save as" = "შენახვა როგორც" +"Save as preset…" = "პრესეტად შენახვა…" +"Save version {key}" = "ვერსიის შენახვა {key}" +"Saved" = "შენახულია" +"Saved {name}" = "შენახულია {name}" +"Say something" = "დაწერეთ რამე" +"Scale" = "მასშტაბი" +"Scatter" = "გაბნევა" +"Scoping: where pickers offer it" = "არე: სად სთავაზობენ ამომრჩევები" +"Screen" = "ეკრანი" +"Scroll behavior" = "გადახვევის ქცევა" +"Scroll to" = "გადახვევა:" +"Scroll to layer" = "შრემდე გადახვევა" +"Scroll with parent" = "მშობელთან ერთად გადახვევა" +"Search plugins" = "პლაგინების ძიება" +"Search" = "ძიება" +"Search actions, layers and tools" = "მოქმედებების, შრეებისა და ხელსაწყოების ძიება" +"Search assets" = "რესურსების ძიება" +"Search comments" = "კომენტარების ძიება" +"Search files and templates" = "ფაილებისა და შაბლონების ძიება" +"Search results" = "ძიების შედეგები" +"Search fonts" = "შრიფტების ძიება" +"Search layers" = "შრეების ძიება" +"Search shortcuts" = "მალსახმობების ძიება" +"Section" = "სექცია" +"Section color" = "სექციის ფერი" +"Sections" = "სექციები" +"Select a frame and add a starting point to present it as a flow." = "მონიშნეთ ჩარჩო და დაამატეთ საწყისი წერტილი, რომ ნაკადად წარადგინოთ." +"Select a frame to preview it" = "მონიშნეთ ჩარჩო მისი წინასწარი ნახვისთვის" +"Select a layer to see its properties and code" = "მონიშნეთ შრე მისი თვისებებისა და კოდის სანახავად" +"Select all" = "ყველაფრის მონიშვნა" +"Select all with same" = "ყველაფრის მონიშვნა იგივე" +"Select layer" = "შრის მონიშვნა" +"Select layers to apply the style to" = "მონიშნეთ შრეები, რომლებზეც სტილი უნდა გამოიყენოთ" +"Select layers with this color" = "ამ ფერის შრეების მონიშვნა" +"Select overlapping shapes" = "გადაფარული ფიგურების მონიშვნა" +"Select points to edit their position, radius and mirroring" = "მონიშნეთ წერტილები მათი პოზიციის, რადიუსისა და არეკვლის შესაცვლელად" +"Select slot" = "სლოტის მონიშვნა" +"Selection colors" = "მონიშნულის ფერები" +"Selection" = "მონიშნული" +"Selection box, {w} by {h} at {x}, {y}: arrows move it, Shift+arrows or {cmd}+arrows size it, Enter selects, Escape cancels" = "მონიშვნის ჩარჩო, {w} × {h}, მდებარეობა {x}, {y}: ისრები გადააადგილებს, Shift+ისრები ან {cmd}+ისრები ზომას ცვლის, Enter მონიშნავს, Escape გააუქმებს" +"Selection box: arrows move it, Shift+arrows or {cmd}+arrows size it, Enter selects, Escape cancels" = "მონიშვნის ჩარჩო: ისრები გადააადგილებს, Shift+ისრები ან {cmd}+ისრები ზომას ცვლის, Enter მონიშნავს, Escape გააუქმებს" +"Send backward" = "უკან გადატანა" +"Send report" = "საჩივრის გაგზავნა" +"Send to back" = "უკანა პლანზე გადატანა" +"Sep" = "სექ" +"Server address (ws://host:47900/)" = "სერვერის მისამართი (ws://host:47900/)" +"Server address" = "სერვერის მისამართი" +"Server for online sharing" = "ონლაინ გაზიარების სერვერი" +"Set as default" = "ნაგულისხმევად დაყენება" +"Set variable" = "ცვლადის დაყენება" +"Set variable mode" = "ცვლადის რეჟიმის დაყენება" +"Set variable {name}" = "ცვლადის დაყენება: {name}" +"Set {name} to {value}" = "{name}-ისთვის {value}-ის დაყენება" +"Shadows" = "ჩრდილები" +"Shape" = "ფიგურა" +"Shape builder" = "ფიგურების კონსტრუქტორი" +"Shape tools" = "ფიგურის ხელსაწყოები" +"Shapes" = "ფიგურები" +"Share" = "გაზიარება" +"Share this file" = "ამ ფაილის გაზიარება" +"Share with a code" = "კოდით გაზიარება" +"Share: live session, export" = "გაზიარება: ცოცხალი სესია, ექსპორტირება" +"Shared" = "გაზიარებულია" +"Shared: copy its code from the file's menu" = "გაზიარებულია: მისი კოდი დააკოპირეთ ფაილის მენიუდან" +"Sharing a file needs the desktop app; here you can join one." = "ფაილის გასაზიარებლად სამუშაო მაგიდის აპია საჭირო; აქ შეგიძლიათ შეუერთდეთ." +"Sharing on the local network (online sharing is off: no server is set in Preferences)." = "ზიარდება ლოკალურ ქსელში (ონლაინ გაზიარება გამორთულია: პარამეტრებში სერვერი არ არის მითითებული)." +"Sharing on your network · {people}" = "ზიარდება თქვენს ქსელში · {people}" +"Sharing online · {people}" = "ზიარდება ონლაინ · {people}" +"Sharing stopped: the file stays on the server, open only to you" = "გაზიარება შეწყდა: ფაილი სერვერზე რჩება და მხოლოდ თქვენთვისაა ღია" +"Show" = "ჩვენება" +"Show all templates" = "ყველა შაბლონის ჩვენება" +"Show as grid" = "ბადის სახით ჩვენება" +"Show as list" = "სიის სახით ჩვენება" +"Show autosaves" = "ავტოშენახვების ჩვენება" +"Show behind transparent areas" = "გამჭვირვალე არეების მიღმა ჩვენება" +"Show comments (Shift+C)" = "კომენტარების ჩვენება (Shift+C)" +"Show device frame" = "მოწყობილობის კორპუსის ჩვენება" +"Show dimensions on objects" = "ზომების ჩვენება ობიექტებზე" +"Show hotspot hints" = "აქტიური არეების მინიშნებების ჩვენება" +"Show in Explorer" = "Explorer-ში ჩვენება" +"Show in Finder" = "Finder-ში ჩვენება" +"Show in folder" = "საქაღალდეში ჩვენება" +"Show only this on the canvas" = "ტილოზე მხოლოდ ამის ჩვენება" +"Show open" = "გახსნილის ჩვენება" +"Show section contents" = "სექციის შიგთავსის ჩვენება" +"Show this layer" = "ამ შრის ჩვენება" +"Show usage" = "გამოყენების ჩვენება" +"Show/Hide" = "ჩვენება/დამალვა" +"Show/Hide UI" = "ინტერფეისის ჩვენება/დამალვა" +"Side by side" = "გვერდიგვერდ" +"Sidebar" = "გვერდითი ზოლი" +"Simplify stroke" = "კონტურის გამარტივება" +"Size" = "ზომა" +"Size jitter" = "ზომის ცვალებადობა" +"Skip ink" = "ხაზგასმის გაწყვეტა ასოებთან" +"Slashed zero" = "გადახაზული ნული" +"Slice" = "ნაჭერი" +"Slices" = "ნაჭრები" +"Slide in" = "შემოსრიალება" +"Slide out" = "გასრიალება" +"Slot" = "სლოტი" +"Slot content" = "სლოტის შიგთავსი" +"Small capitals" = "პატარა მთავრული" +"Small caps" = "პატარა მთავრული" +"Smart animate" = "ჭკვიანი ანიმაცია" +"Snap to pixel grid" = "პიქსელურ ბადეზე მიბმა" +"Social and slides" = "სოციალური ქსელები და სლაიდები" +"Social media" = "სოციალური ქსელები" +"Social post sizes" = "სოციალური ქსელების პოსტების ზომები" +"Soft light" = "რბილი ღია" +"Solid" = "ერთფეროვანი" +"Some variants have the same property values: give each one a unique combination" = "ზოგ ვარიანტს თვისებების ერთნაირი მნიშვნელობები აქვს: მიეცით თითოეულს უნიკალური კომბინაცია" +"Sort" = "სორტირება" +"Sort by" = "სორტირება:" +"Sort by date" = "თარიღის მიხედვით სორტირება" +"Sort by page" = "გვერდის მიხედვით სორტირება" +"Sort by {column}" = "სორტირება: {column}" +"Space around" = "დაშორება გარშემო" +"Space between" = "დაშორება შორის" +"Space drag" = "Space და გადათრევა" +"Space evenly" = "თანაბარი დაშორება" +"Space+drag" = "Space+გადათრევა" +"Spacing" = "ინტერვალი" +"Spam, stolen work, abuse…" = "სპამი, მოპარული ნამუშევარი, შეურაცხყოფა…" +"Speech bubble" = "სიტყვის ბუშტი" +"Splay" = "გაშლილობა" +"Spotlight me" = "ჩემი ხედის ჩვენება ყველასთვის" +"Spread" = "გაფართოება" +"Square" = "კვადრატი" +"Stamp" = "შტამპი" +"Stamps" = "შტამპები" +"Standard" = "სტანდარტული" +"Standard ligatures" = "სტანდარტული ლიგატურები" +"Star" = "ვარსკვლავი" +"Stars" = "ვარსკვლავები" +"Start" = "დასაწყისი" +"Start ascending from / stop descending at" = "ზრდადობით დაწყება / კლებადობით დასრულება" +"Start from a blank canvas" = "ცარიელი ტილოთი დაწყება" +"Start from a template" = "შაბლონით დაწყება" +"Start on path" = "დასაწყისი ტრაექტორიაზე" +"Start point" = "საწყისი წერტილი" +"Start sharing" = "გაზიარების დაწყება" +"Start sharing with the Share button, then pass on the code it gives" = "გაზიარება დაიწყეთ ღილაკით „გაზიარება“, შემდეგ გადაეცით მის მიერ მოცემული კოდი" +"Starting to share…" = "გაზიარების დაწყება…" +"Status" = "სტატუსი" +"Sticky" = "მიმაგრებული" +"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" = "ტექსტი" +"Stroke" = "კონტური" +"Stroke style" = "კონტურის სტილი" +"Stroke weight" = "კონტურის სისქე" +"Strokes" = "კონტურები" +"Style" = "სტილი" +"Styles" = "სტილები" +"Stylistic set 1" = "სტილისტური ნაკრები 1" +"Stylistic set 2" = "სტილისტური ნაკრები 2" +"Stylistic set 3" = "სტილისტური ნაკრები 3" +"Stylistic set 4" = "სტილისტური ნაკრები 4" +"Stylistic set 5" = "სტილისტური ნაკრები 5" +"Subtract" = "გამოკლება" +"Subtracts" = "გამოკლებები" +"Suffix" = "სუფიქსი" +"Suspend snapping while dragging" = "მიბმის შეჩერება გადათრევისას" +"Swap instance" = "ინსტანსის ჩანაცვლება" +"Swap library: use another library's assets of the same names" = "ბიბლიოთეკის ჩანაცვლება: სხვა ბიბლიოთეკის იმავე სახელის რესურსების გამოყენება" +"Swap orientation" = "ორიენტაციის შეცვლა" +"Swap overlay" = "გადაფარვის ჩანაცვლება" +"Swap to {name}" = "ჩანაცვლება: {name}" +"Swap width and height" = "სიგანისა და სიმაღლის გაცვლა" +"Swapped instance" = "ჩანაცვლებული ინსტანსი" +"Swapped {n} assets" = { one = "ჩანაცვლებული რესურსი: {n}", other = "ჩანაცვლებული რესურსი: {n}" } +"Swapped {n} assets; {missed} had no match and still use the old library" = { one = "ჩანაცვლებული რესურსი: {n}; {missed} ვერ დაემთხვა და ისევ ძველ ბიბლიოთეკას იყენებს", other = "ჩანაცვლებული რესურსი: {n}; {missed} ვერ დაემთხვა და ისევ ძველ ბიბლიოთეკას იყენებს" } +"Sweep" = "სიგრძე" +"System" = "სისტემა" +"Tab / Shift+Tab" = "Tab / Shift+Tab" +"Table" = "ცხრილი" +"Tablet" = "პლანშეტი" +"Tabular numbers" = "ცხრილური ციფრები" +"Take the values from the first selected layer" = "მნიშვნელობების აღება პირველი მონიშნული შრიდან" +"Taper" = "წაწვეტება" +"Taper both ends" = "ორივე ბოლოს წაწვეტება" +"Tell the server's operator what is wrong with it" = "აცნობეთ სერვერის ოპერატორს, რა არის მასში არასწორი" +"Temperature" = "ტემპერატურა" +"Template" = "შაბლონი" +"Templates" = "შაბლონები" +"Terms" = "პირობები" +"Text" = "ტექსტი" +"Text align" = "ტექსტის სწორება" +"Text content" = "ტექსტის შიგთავსი" +"Text size" = "ტექსტის ზომა" +"Text style" = "ტექსტის სტილი" +"Text styles" = "ტექსტის სტილები" +"Texts" = "ტექსტები" +"Texture" = "ტექსტურა" +"The canvas previews colors in sRGB; exported PNG, JPG and WebP files are tagged Display P3." = "ტილო ფერებს sRGB-ში აჩვენებს; ექსპორტირებულ PNG, JPG და WebP ფაილებს Display P3 ჭდე აქვს." +"The destination document is unavailable. Nothing was imported." = "დანიშნულების დოკუმენტი მიუწვდომელია. იმპორტი არ შესრულდა." +"The destination document was closed. Nothing was imported." = "დანიშნულების დოკუმენტი დაიხურა. იმპორტი არ შესრულდა." +"The destination page was removed. Nothing was imported." = "დანიშნულების გვერდი წაიშალა. იმპორტი არ შესრულდა." +"The file is deleted from the server: nobody can open or join it from there again, and this can't be undone. Copies that are open stay with whoever has them." = "ფაილი სერვერიდან წაიშლება: იქიდან მის გახსნას ან შეერთებას ვეღარავინ შეძლებს და ამის დაბრუნება შეუძლებელია. ღია ასლები მათ მფლობელებთან რჩება." +"The file now holds another library" = "ფაილი ახლა სხვა ბიბლიოთეკას შეიცავს" +"The file now holds another library (\"{name}\")" = "ფაილი ახლა სხვა ბიბლიოთეკას შეიცავს („{name}“)" +"The files you shared or published online from this computer. They stay on their server until you remove them." = "ფაილები, რომლებიც ამ კომპიუტერიდან ონლაინ გააზიარეთ ან გამოაქვეყნეთ. ისინი თავიანთ სერვერზე რჩება, სანამ არ წაშლით." +"The link carries the session's secret: share it only with people you invite." = "ბმული სესიის საიდუმლო გასაღებს შეიცავს: გააზიარეთ მხოლოდ მოწვეულ ადამიანებთან." +"The name in your online files and on the server. The document inside keeps its own name." = "სახელი თქვენს ონლაინ ფაილებსა და სერვერზე. შიგნით არსებული დოკუმენტი თავის სახელს ინარჩუნებს." +"The selection uses several text styles: click to apply one" = "მონიშნული ტექსტის რამდენიმე სტილს იყენებს: დაწკაპეთ ერთის გამოსაყენებლად" +"The server deletes this file if nobody opens it before {date}." = "სერვერი ამ ფაილს წაშლის, თუ მას {date}-მდე არავინ გახსნის." +"The whole file" = "მთელი ფაილი" +"Theme" = "თემა" +"There's no destination for that action" = "ამ მოქმედებას დანიშნულება არ აქვს" +"Thickness" = "სისქე" +"This branch can't be reviewed: {e}" = "ამ განშტოების განხილვა შეუძლებელია: {e}" +"This branch was merged or archived." = "ეს განშტოება შერწყმულია ან დაარქივებულია." +"This computer only" = "მხოლოდ ეს კომპიუტერი" +"This computer remembers the owner token, so the file can be managed from the home screen's Online list. Copy it to manage the file from somewhere else: it is a password for this file and won't be shown again." = "ეს კომპიუტერი იმახსოვრებს მფლობელის ტოკენს, ამიტომ ფაილის მართვა საწყისი ეკრანის „ონლაინ“ სიიდან შეიძლება. ფაილის სხვა ადგილიდან სამართავად დააკოპირეთ ის: ეს ამ ფაილის პაროლია და აღარ გამოჩნდება." +"This condition is edited as an expression" = "ეს პირობა ექსპრესიად რედაქტირდება" +"This file" = "ეს ფაილი" +"This file has unsaved changes" = "ამ ფაილს შეუნახავი ცვლილებები აქვს" +"This file uses fonts that aren't installed on this computer. Their text is drawn in a fallback font until you install them." = "ეს ფაილი იყენებს შრიფტებს, რომლებიც ამ კომპიუტერზე დაინსტალირებული არ არის. მათი ტექსტი სათადარიგო შრიფტით გამოისახება, სანამ მათ არ დააინსტალირებთ." +"This file is on {server}, open only to you. Share it to let others in with a new code." = "ეს ფაილი {server}-ზეა და მხოლოდ თქვენთვისაა ღია. გააზიარეთ, რომ სხვები ახალი კოდით შემოუშვათ." +"This frame is new on the branch." = "ეს ჩარჩო განშტოებაში ახალია." +"This frame isn't on the branch." = "ეს ჩარჩო განშტოებაში არ არის." +"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" = "ფილის ტიპი" +"Tint" = "ტონირება" +"Title" = "დასახელება" +"Title case" = "სათაურის რეგისტრი" +"To share this library with your team, publish the file to a server (Share ▸ Publish to server…), then publish the library there." = "ამ ბიბლიოთეკის გუნდთან გასაზიარებლად გამოაქვეყნეთ ფაილი სერვერზე (გაზიარება ▸ სერვერზე გამოქვეყნება…), შემდეგ იქ გამოაქვეყნეთ ბიბლიოთეკა." +"Toggle" = "გადართვა" +"Toggle light/dark theme" = "ღია/ბნელი თემის გადართვა" +"Toggle visibility" = "ხილვადობის ჩართვა/გამორთვა" +"Tools" = "ხელსაწყოები" +"Top" = "ზემოთ" +"Top & bottom" = "ზემოთ და ქვემოთ" +"Top left radius" = "ზედა მარცხენა რადიუსი" +"Top right radius" = "ზედა მარჯვენა რადიუსი" +"Transform" = "გარდაქმნა" +"Triangle" = "სამკუთხედი" +"Triangle arrow" = "სამკუთხა ისარი" +"Trigger" = "გამშვები" +"True" = "ჭეშმარიტი" +"Truncate text" = "ტექსტის შეკვეცა" +"Truncate with an ellipsis" = "შეკვეცა მრავალწერტილით" +"Try again" = "ხელახლა ცდა" +"Try another search or filter." = "სცადეთ სხვა ძიება ან ფილტრი." +"Try another search, or browse templates." = "სცადეთ სხვა ძიება ან დაათვალიერეთ შაბლონები." +"Try another word, or look at every kind." = "სცადეთ სხვა სიტყვა ან ნახეთ ყველა სახეობა." +"Turn a layer into a component, reuse it, and change every copy at once" = "შრე აქციეთ კომპონენტად, გამოიყენეთ ხელახლა და შეცვალეთ ყველა ასლი ერთდროულად" +"Two digits" = "ორი ციფრი" +"Type" = "ტიპი" +"Type settings" = "ტექსტის პარამეტრები" +"Type the join code you were given, or paste an invite link." = "შეიყვანეთ მოცემული შეერთების კოდი ან ჩასვით მოსაწვევი ბმული." +"Typography" = "ტიპოგრაფია" +"Underline" = "ხაზგასმული" +"Undo takes back only your own changes; what others changed since stays." = "დაბრუნება მხოლოდ თქვენს ცვლილებებს აუქმებს; რაც სხვებმა ამასობაში შეცვალეს, რჩება." +"Ungroup" = "დაჯგუფების მოხსნა" +"Uniform" = "ერთგვაროვანი" +"Union" = "გაერთიანება" +"Unions" = "გაერთიანებები" +"Unknown — treated as sRGB" = "უცნობი — განიხილება როგორც sRGB" +"Unread" = "წაუკითხავი" +"Unresolve" = "ხელახლა გახსნა" +"Unsaved changes" = "შეუნახავი ცვლილებები" +"Unsaved changes in {names}. 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" = "ზემოთ" +"Update plugin" = "პლაგინის განახლება" +"Update" = "განახლება" +"Update from main" = "მთავარიდან განახლება" +"Update from selection" = "მონიშნულიდან განახლება" +"Updated {components} and {styles}" = "განახლდა {components} და {styles}" +"Updated {name}" = "განახლდა: {name}" +"Updating the graphics driver often helps. To use OpenGL only, start FigCraft from a Command Prompt with:" = "ხშირად გრაფიკის დრაივერის განახლება შველის. მხოლოდ OpenGL-ის გამოსაყენებლად FigCraft გაუშვით ბრძანების სტრიქონიდან (Command Prompt) ასე:" +"Updating the graphics driver often helps. WGPU_BACKEND (vulkan, gl, metal) names another graphics API to try." = "ხშირად გრაფიკის დრაივერის განახლება შველის. WGPU_BACKEND-ით (vulkan, gl, metal) შეგიძლიათ სხვა გრაფიკული API სცადოთ." +"Uploads this file to a FigCraft server and keeps this tab in sync with it. You get a link to edit and a link to view." = "ტვირთავს ამ ფაილს FigCraft-ის სერვერზე და ამ ჩანართს მასთან სინქრონში ინახავს. მიიღებთ რედაქტირებისა და ნახვის ბმულებს." +"Uppercase" = "მთავრული" +"Use a fixed value" = "ფიქსირებული მნიშვნელობის გამოყენება" +"Use an expression, e.g. count + 1" = "ექსპრესიის გამოყენება, მაგ. count + 1" +"Use as mask" = "ნიღბად გამოყენება" +"Use auto layout" = "ავტოგანლაგების გამოყენება" +"Use number keys for opacity" = "გაუმჭვირვალობისთვის ციფრული კლავიშების გამოყენება" +"Use the .figcraft or .fig extension." = "გამოიყენეთ .figcraft ან .fig გაფართოება." +"Use the layer's fill" = "შრის შევსების გამოყენება" +"Used in {files} · {instances}" = "გამოიყენება: {files} · {instances}" +"Username" = "მომხმარებლის სახელი" +"Value" = "მნიშვნელობა" +"Uses variables: each layer shows them in its own mode" = "იყენებს ცვლადებს: თითოეული შრე მათ თავის რეჟიმში აჩვენებს" +"Variable" = "ცვლადი" +"Variable collections" = "ცვლადების კოლექციები" +"Variable modes" = "ცვლადების რეჟიმები" +"Variable width" = "ცვლადი სიგანე" +"Variables" = "ცვლადები" +"Variables JSON…" = "ცვლადების JSON…" +"Variables store reusable values (colours, numbers, strings, booleans) with modes such as Light and Dark." = "ცვლადები ინახავს ხელახლა გამოსაყენებელ მნიშვნელობებს (ფერები, რიცხვები, სტრიქონები, ლოგიკური მნიშვნელობები) ისეთი რეჟიმებით, როგორიცაა ღია და მუქი." +"Variant" = "ვარიანტი" +"Vector" = "ვექტორი" +"Vectors" = "ვექტორები" +"Version" = "ვერსია" +"Version history" = "ვერსიების ისტორია" +"Version {version}" = "ვერსია {version}" +"Version {v} · {components} · {styles} · {variables}" = "ვერსია {v} · {components} · {styles} · {variables}" +"Vertical" = "ვერტიკალური" +"Vertical align" = "ვერტიკალური სწორება" +"Vertical hex" = "ვერტიკალური ექვსკუთხედი" +"Vertical padding" = "ვერტიკალური შიდა დაშორება" +"Vertical spacing" = "ვერტიკალური დაშორება" +"Vertical trim" = "ვერტიკალური ჩამოჭრა" +"View" = "ხედი" +"View link" = "ნახვის ბმული" +"View link copied" = "ნახვის ბმული დაკოპირდა" +"View only" = "მხოლოდ ნახვა" +"View only: you see every change as it happens but can't edit. Leave to keep a copy you can edit." = "მხოლოდ ნახვა: ყოველ ცვლილებას ხედავთ მისი მოხდენისთანავე, მაგრამ რედაქტირება არ შეგიძლიათ. რედაქტირებადი ასლის შესანარჩუნებლად გადით სესიიდან." +"Viewing “{name}”" = "დათვალიერება: „{name}“" +"Violet" = "იისფერი" +"Visibility" = "ხილვადობა" +"Visible" = "ხილული" +"Vivid" = "ცოცხალი" +"WGPU_BACKEND is set and names the graphics API to use; unsetting it lets FigCraft choose." = "WGPU_BACKEND დაყენებულია და გამოსაყენებელ გრაფიკულ API-ს განსაზღვრავს; მისი მოხსნის შემდეგ FigCraft თავად აირჩევს." +"Warm" = "თბილი" +"Watch" = "საათი" +"Wavy" = "ტალღოვანი" +"Web" = "ვები" +"Websites" = "ვებსაიტები" +"WebSocket URL" = "WebSocket URL" +"WebSocket URL copied" = "WebSocket URL დაკოპირდა" +"Wedge" = "სოლი" +"Weight" = "სისქე" +"What you try here changes this preview and the code below, not the file" = "რასაც აქ ცდით, ცვლის ამ გადახედვას და ქვემოთ კოდს და არა ფაილს" +"What changed since it was marked ready for dev" = "რა შეიცვალა მზად დეველოპერისთვის მონიშვნის შემდეგ" +"What is wrong with this file? The server's operator gets your report." = "რა არის ამ ფაილში არასწორი? სერვერის ოპერატორი მიიღებს თქვენს საჩივარს." +"What it's for" = "რისთვისაა" +"What this changed from isn't known: it changed before FigCraft kept a copy of ready-for-dev frames. Resolve the change (or mark it ready again) to compare from now on." = "არ არის ცნობილი, რისგან შეიცვალა: ცვლილება მოხდა მანამ, სანამ FigCraft მზად დეველოპერისთვის ჩარჩოების ასლებს შეინახავდა. გადაწყვიტეთ ცვლილება (ან ხელახლა მონიშნეთ მზადად), რომ ამიერიდან შედარდეს." +"While hovering" = "მაჩვენებლის მიტანისას" +"While pressing" = "დაჭერისას" +"White" = "თეთრი" +"Who runs it, its terms and how long it keeps files" = "ვინ მართავს, მისი პირობები და რამდენ ხანს ინახავს ფაილებს" +"Why it changed" = "რატომ შეიცვალა" +"Widget" = "ვიჯეტი" +"Widgets" = "ვიჯეტები" +"Width" = "სიგანე" +"Width point" = "სიგანის წერტილი" +"Width profile" = "სიგანის პროფილი" +"Wireframe kit" = "კარკასების ნაკრები" +"With the layers' export settings" = "შრეების ექსპორტის პარამეტრებით" +"Wrap" = "გადატანა" +"Wrap in new section" = "ახალ სექციაში მოქცევა" +"Yellow" = "ყვითელი" +"Yesterday" = "გუშინ" +"You" = "თქვენ" +"You'll get the shared file as a new tab and edit it together." = "გაზიარებულ ფაილს ახალ ჩანართად მიიღებთ და ერთად დაარედაქტირებთ." +"You're spotlighting: everyone follows your view" = "ყურადღება თქვენზეა: ყველა თქვენს ხედს მიჰყვება" +"Your copy of the file stays open" = "თქვენი ფაილის ასლი ღია რჩება" +"Your copy stays open; the file stays on the server" = "თქვენი ასლი ღია რჩება; ფაილი სერვერზე რჩება" +"Your name" = "თქვენი სახელი" +"Your name in comments" = "თქვენი სახელი კომენტარებში" +"Your system asks for reduced motion: animations stay off." = "თქვენი სისტემა ითხოვს მოძრაობის შემცირებას: ანიმაციები გამორთული რჩება." +"Z" = "Z" +"Z to A" = "ჰ-დან ა-მდე" +"Zoom" = "მასშტაბი" +"Zoom around the pointer" = "მასშტაბირება მაჩვენებლის გარშემო" +"Zoom device to fill window" = "მოწყობილობის მასშტაბირება ფანჯრის შესავსებად" +"Zoom in" = "გადიდება" +"Zoom out" = "დაპატარავება" +"Zoom to 100%" = "მასშტაბი 100%" +"Zoom to 200%" = "მასშტაბი 200%" +"Zoom to 50%" = "მასშტაბი 50%" +"Zoom to fit" = "ეკრანში ჩატევა" +"Zoom to selection" = "მონიშნულის მასშტაბირება" +"Zoom tool: click to zoom in, drag an area" = "მასშტაბის ხელსაწყო: დაწკაპეთ გასადიდებლად, მონიშნეთ არე გადათრევით" +"Zoom tool: zoom out" = "მასშტაბის ხელსაწყო: დაპატარავება" +"and {n} more" = { one = "და კიდევ {n}", other = "და კიდევ {n}" } +"bleed marks" = "ჭრისპირა არის ნიშნები" +"by {author}" = "ავტორი: {author}" +"call any command its access allows" = "ნებისმიერი ბრძანების გამოძახება, რასაც მისი წვდომა უშვებს" +"call only: {commands}" = "მხოლოდ გამოძახება: {commands}" +"can edit the document" = "შეუძლია დოკუმენტის რედაქტირება" +"conditional prototype actions nested too deeply were skipped" = "ზედმეტად ღრმად ჩადგმული პირობითი მოქმედებები პროტოტიპში გამოტოვებულია" +"conditional prototype actions with conditions that can't be evaluated were skipped" = "პროტოტიპის პირობითი მოქმედებები, რომელთა პირობების გამოთვლა ვერ ხერხდება, გამოტოვებულია" +"connector label background was skipped: style data is missing" = "შემაერთებლის იარლიყის ფონი გამოტოვებულია: სტილის მონაცემები აკლია" +"connector label was skipped: background bounds are invalid" = "შემაერთებლის იარლიყი გამოტოვებულია: ფონის საზღვრები არასწორია" +"connector label was skipped: background transform is invalid" = "შემაერთებლის იარლიყი გამოტოვებულია: ფონის გარდაქმნა არასწორია" +"connector label was skipped: composed text transform is invalid" = "შემაერთებლის იარლიყი გამოტოვებულია: ტექსტის შედგენილი გარდაქმნა არასწორია" +"connector label was skipped: derived background bounds are missing" = "შემაერთებლის იარლიყი გამოტოვებულია: ფონის გამოთვლილი საზღვრები აკლია" +"connector label was skipped: derived text bounds are missing" = "შემაერთებლის იარლიყი გამოტოვებულია: ტექსტის გამოთვლილი საზღვრები აკლია" +"connector label was skipped: text bounds are invalid" = "შემაერთებლის იარლიყი გამოტოვებულია: ტექსტის საზღვრები არასწორია" +"connector label was skipped: text style data is missing" = "შემაერთებლის იარლიყი გამოტოვებულია: ტექსტის სტილის მონაცემები აკლია" +"connector label was skipped: text transform is invalid" = "შემაერთებლის იარლიყი გამოტოვებულია: ტექსტის გარდაქმნა არასწორია" +"connector labels were skipped" = "შემაერთებლების იარლიყები გამოტოვებულია" +"connector layers import as static frame/vector snapshots with labels when present; live endpoint bindings are not retained" = "შემაერთებლის შრეები იმპორტირდება ჩარჩოს ან ვექტორის სტატიკური სნეპშოტების სახით, იარლიყებით (თუ არის); ბოლოების ცოცხალი მიბმები არ ნარჩუნდება" +"connector snapshot vector data is missing; connector was left empty" = "შემაერთებლის სნეპშოტის ვექტორული მონაცემები აკლია; შემაერთებელი ცარიელი დარჩა" +"connectors whose ends can't be placed are empty" = "შემაერთებლები, რომელთა ბოლოების განთავსება ვერ ხერხდება, ცარიელია" +"crop marks" = "ჭრის ნიშნები" +"damaged vector geometry was skipped" = "დაზიანებული ვექტორული გეომეტრია გამოტოვებულია" +"damaged vector networks import as outlines" = "დაზიანებული ვექტორული ქსელები კონტურებად იმპორტირდება" +"development: {path}" = "დეველოპმენტი: {path}" +"document children other than pages were skipped" = "დოკუმენტის ქვეელემენტები, რომლებიც გვერდები არ არის, გამოტოვებულია" +"duplicate grid track sizing entries use equal tracks" = "ბადის ტრეკის ზომის დუბლირებული ჩანაწერებისას ტრეკები თანაბარია" +"e.g. count + 1" = "მაგ. count + 1" +"e.g. count >= 3 and not done" = "მაგ. count >= 3 and not done" +"elbowed and curved connectors without a saved path import as straight lines" = "შენახული ტრაექტორიის გარეშე კუთხოვანი და მრუდი შემაერთებლები სწორ ხაზებად იმპორტირდება" +"entries without a node type were skipped" = "კვანძის ტიპის გარეშე ჩანაწერები გამოტოვებულია" +"figcraft:// links from a browser, chat or email open this app and join the session." = "ბრაუზერიდან, ჩატიდან ან ელფოსტიდან figcraft:// ბმულები ხსნის ამ აპს და უერთდება სესიას." +"filled areas of a vector with their own fill use the layer's fill" = "ვექტორის შევსებული არეები საკუთარი შევსებით შრის შევსებას იყენებს" +"from {server}" = "წყარო: {server}" +"gamepad triggers without exactly one known controller button were skipped" = "გეიმპადის გამშვებები, რომლებსაც ზუსტად ერთი ცნობილი კონტროლერის ღილაკი არ აქვს, გამოტოვებულია" +"grid track list exceeds 1000 entries; the tracks use the default equal sizing" = "ბადის ტრეკების სია 1000 ჩანაწერს აღემატება; ტრეკები ნაგულისხმევ თანაბარ ზომებს იყენებს" +"grid track sizing is unsupported or malformed; affected tracks use equal sizing" = "ბადის ტრეკის ზომა მხარდაუჭერელი ან დაზიანებულია; შესაბამისი ტრეკები თანაბარ ზომებს იყენებს" +"grid track sizing list exceeds 1000 entries; the tracks use the default equal sizing" = "ბადის ტრეკების ზომების სია 1000 ჩანაწერს აღემატება; ტრეკები ნაგულისხმევ თანაბარ ზომებს იყენებს" +"hidden" = "დამალული" +"iOS" = "iOS" +"image filters are ambiguous because both filter containers are present; filter conversion was skipped" = "გამოსახულების ფილტრები ორაზროვანია, რადგან ფილტრების ორივე კონტეინერია მოცემული; ფილტრების გარდაქმნა გამოტოვებულია" +"image filters use FigCraft's approximate renderer" = "გამოსახულების ფილტრები FigCraft-ის მიახლოებითი რენდერით გამოისახება" +"instance swap properties whose default component isn't in the file were skipped" = "ინსტანსის ჩანაცვლების თვისებები, რომელთა ნაგულისხმევი კომპონენტი ფაილში არ არის, გამოტოვებულია" +"instances inside their own main component were imported as frames" = "საკუთარი მთავარი კომპონენტის შიგნით მყოფი ინსტანსები ჩარჩოებად იმპორტირდა" +"instances of components that aren't in the file show no layers" = "იმ კომპონენტების ინსტანსები, რომლებიც ფაილში არ არის, შრეებს არ აჩვენებს" +"instances of components that aren't in the file were imported as frames" = "იმ კომპონენტების ინსტანსები, რომლებიც ფაილში არ არის, ჩარჩოებად იმპორტირდა" +"instances that would nest a component inside itself were imported as frames" = "ინსტანსები, რომლებიც კომპონენტს საკუთარ თავში ჩადგამდა, ჩარჩოებად იმპორტირდა" +"instances without a main component were imported as empty frames" = "მთავარი კომპონენტის გარეშე ინსტანსები ცარიელ ჩარჩოებად იმპორტირდა" +"invalid internal .fig text path data was ignored" = ".fig-ის შიდა ტექსტის ტრაექტორიის არასწორი მონაცემები იგნორირებულია" +"invalid or oversized vector networks import as outlines" = "არასწორი ან ზედმეტად დიდი ვექტორული ქსელები კონტურებად იმპორტირდება" +"invalid or oversized vector path data was skipped" = "ვექტორული ტრაექტორიის არასწორი ან ზედმეტად დიდი მონაცემები გამოტოვებულია" +"just now" = "ახლახან" +"key triggers without a supported key were skipped" = "მხარდაჭერილი კლავიშის გარეშე კლავიშის გამშვებები გამოტოვებულია" +"key up triggers import as key presses" = "კლავიშის აშვების გამშვებები კლავიშის დაჭერებად იმპორტირდება" +"layers without a position were placed at their parent's origin" = "პოზიციის გარეშე შრეები მშობლის საწყის წერტილში განთავსდა" +"layers without a type were skipped" = "ტიპის გარეშე შრეები გამოტოვებულია" +"legacy image filter container is unsupported; filter conversion was skipped" = "გამოსახულების ფილტრების ძველი კონტეინერი მხარდაუჭერელია; ფილტრების გარდაქმნა გამოტოვებულია" +"locked" = "ჩაკეტილი" +"malformed image filters container was skipped" = "გამოსახულების ფილტრების დაზიანებული კონტეინერი გამოტოვებულია" +"malformed image paintFilter container was skipped" = "გამოსახულების paintFilter-ის დაზიანებული კონტეინერი გამოტოვებულია" +"malformed slot content assignment was skipped" = "სლოტის შიგთავსის დაზიანებული მინიჭება გამოტოვებულია" +"no bleed" = "ჭრისპირა არის გარეშე" +"nobody else here yet" = "აქ ჯერ სხვა არავინაა" +"non-default slot settings are not represented and were skipped" = "სლოტის არანაგულისხმევი პარამეტრები არ არის წარმოდგენილი და გამოტოვებულია" +"now" = "ახლა" +"only in a text style or variable" = "მხოლოდ ტექსტის სტილში ან ცვლადში" +"or" = "ან" +"point stroke joins were preserved; dashed or variable-width strokes render with the layer join" = "წერტილების კონტურის შეერთებები შენარჩუნებულია; ტირეებიანი ან ცვლადი სისქის კონტურები შრის შეერთებით გამოისახება" +"preferred slot component keys cannot be resolved to file nodes and were skipped" = "სლოტის სასურველი კომპონენტების გასაღებები ფაილის კვანძებთან ვერ დაკავშირდა და გამოტოვებულია" +"prototype actions of unknown types were skipped" = "უცნობი ტიპის პროტოტიპის მოქმედებები გამოტოვებულია" +"prototype actions without a destination in the file were skipped" = "პროტოტიპის მოქმედებები, რომელთა დანიშნულება ფაილში არ არის, გამოტოვებულია" +"prototype expressions on variables that aren't in the variables JSON were skipped" = "პროტოტიპის ექსპრესიები იმ ცვლადებზე, რომლებიც ცვლადების JSON-ში არ არის, გამოტოვებულია" +"prototype expressions with unsupported functions were skipped" = "მხარდაუჭერელი ფუნქციების შემცველი პროტოტიპის ექსპრესიები გამოტოვებულია" +"prototype interactions without supported actions were skipped" = "მხარდაჭერილი მოქმედებების გარეშე პროტოტიპის ურთიერთქმედებები გამოტოვებულია" +"prototype navigations of unknown types were skipped" = "უცნობი ტიპის პროტოტიპის ნავიგაციები გამოტოვებულია" +"prototype transitions of unknown types play instantly" = "უცნობი ტიპის პროტოტიპის გადასვლები მყისიერად სრულდება" +"prototype triggers of unknown types were skipped" = "უცნობი ტიპის პროტოტიპის გამშვებები გამოტოვებულია" +"put a widget on the canvas" = "ვიჯეტის ტილოზე განთავსება" +"read and edit the document" = "დოკუმენტის წაკითხვა და რედაქტირება" +"read the document" = "დოკუმენტის წაკითხვა" +"reads the document" = "კითხულობს დოკუმენტს" +"set-variable actions on variables that aren't in the variables JSON were skipped" = "ცვლადის დაყენების მოქმედებები იმ ცვლადებზე, რომლებიც ცვლადების JSON-ში არ არის, გამოტოვებულია" +"set-variable actions with values that can't be evaluated were skipped" = "ცვლადის დაყენების მოქმედებები, რომელთა მნიშვნელობის გამოთვლა ვერ ხერხდება, გამოტოვებულია" +"set-variable actions with values that don't fit the variable were skipped" = "ცვლადის დაყენების მოქმედებები, რომელთა მნიშვნელობა ცვლადს არ შეესაბამება, გამოტოვებულია" +"set-variable actions without a value were skipped" = "მნიშვნელობის გარეშე ცვლადის დაყენების მოქმედებები გამოტოვებულია" +"set-variable-mode actions on collections or modes that aren't in the variables JSON were skipped" = "ცვლადის რეჟიმის დაყენების მოქმედებები იმ კოლექციებზე ან რეჟიმებზე, რომლებიც ცვლადების JSON-ში არ არის, გამოტოვებულია" +"slot content assignment target is missing or is not slot content" = "სლოტის შიგთავსის მინიჭების სამიზნე აკლია ან სლოტის შიგთავსი არ არის" +"slot content assignment uses the empty GUID sentinel and was skipped" = "სლოტის შიგთავსის მინიჭება ცარიელ GUID მარკერს იყენებს და გამოტოვებულია" +"slot layers without a matching slot property were imported as frames" = "სლოტის შრეები, რომლებსაც შესაბამისი სლოტის თვისება არ აქვს, ჩარჩოებად იმპორტირდა" +"some vector points refer to point settings missing from the file; they use the layer's joins and plain corners" = "ზოგი ვექტორული წერტილი მიუთითებს წერტილის პარამეტრებზე, რომლებიც ფაილს აკლია; ისინი შრის შეერთებებსა და ჩვეულებრივ კუთხეებს იყენებს" +"some vector segments refer to style settings missing from the file; they use the layer's stroke" = "ზოგი ვექტორული სეგმენტი მიუთითებს სტილის პარამეტრებზე, რომლებიც ფაილს აკლია; ისინი შრის კონტურს იყენებს" +"stale or invalid grid track sizing entries were ignored" = "ბადის ტრეკის ზომის მოძველებული ან არასწორი ჩანაწერები იგნორირებულია" +"stars and polygons without point counts or geometry use the default point count" = "ვარსკვლავები და მრავალკუთხედები წვეროების რაოდენობის ან გეომეტრიის გარეშე წვეროების ნაგულისხმევ რაოდენობას იყენებს" +"style settings of some vector segments aren't supported; those segments use the layer's stroke" = "ზოგი ვექტორული სეგმენტის სტილის პარამეტრები მხარდაუჭერელია; ეს სეგმენტები შრის კონტურს იყენებს" +"text path underline or strikethrough decoration is not rendered" = "ტრაექტორიაზე განთავსებული ტექსტის ხაზგასმა ან გადახაზვა არ გამოისახება" +"text style overrides refer to missing table entries" = "ტექსტის სტილის გადაფარვები ცხრილის არარსებულ ჩანაწერებზე მიუთითებს" +"the file has no pages" = "ფაილს გვერდები არ აქვს" +"the plugin" = "პლაგინი" +"the variables JSON has no `variableCollections` (expected the local variables response) and was ignored" = "ცვლადების JSON-ს `variableCollections` არ აქვს (მოსალოდნელი იყო ლოკალური ცვლადების პასუხი) და იგნორირებულია" +"this session only" = "მხოლოდ ეს სესია" +"unknown component property types were skipped" = "კომპონენტის თვისებების უცნობი ტიპები გამოტოვებულია" +"variable aliases to variables that aren't in the variables JSON (or of another type) were skipped" = "ცვლადის ალიასები იმ ცვლადებზე, რომლებიც ცვლადების JSON-ში არ არის (ან სხვა ტიპისაა), გამოტოვებულია" +"variable bindings to properties the layer doesn't have were skipped" = "ცვლადების მიბმები იმ თვისებებზე, რომლებიც შრეს არ აქვს, გამოტოვებულია" +"variable bindings to variables that aren't in the variables JSON (library variables) were skipped" = "ცვლადების მიბმები იმ ცვლადებზე, რომლებიც ცვლადების JSON-ში არ არის (ბიბლიოთეკის ცვლადები), გამოტოვებულია" +"variable bindings were skipped: the file JSON has no variable definitions (pass the local variables JSON as `variables`, or embed its `meta` next to the file JSON)" = "ცვლადების მიბმები გამოტოვებულია: ფაილის JSON-ში ცვლადების განსაზღვრებები არ არის (გადაეცით ლოკალური ცვლადების JSON როგორც `variables`, ან ჩააშენეთ მისი `meta` ფაილის JSON-ის გვერდით)" +"variable bindings whose variable type doesn't fit the property were skipped" = "ცვლადების მიბმები, რომელთა ცვლადის ტიპი თვისებას არ შეესაბამება, გამოტოვებულია" +"variable collections without modes were skipped" = "რეჟიმების გარეშე ცვლადების კოლექციები გამოტოვებულია" +"variable mode lookups in prototype expressions that aren't a variable and one of its modes were skipped" = "პროტოტიპის ექსპრესიებში ცვლადის რეჟიმის მოძიებები, რომლებიც ცვლადსა და მის ერთ-ერთ რეჟიმს არ წარმოადგენს, გამოტოვებულია" +"variable modes of collections that aren't in the file were skipped" = "იმ კოლექციების ცვლადის რეჟიმები, რომლებიც ფაილში არ არის, გამოტოვებულია" +"variable modes of collections that aren't in the variables JSON were skipped" = "იმ კოლექციების ცვლადის რეჟიმები, რომლებიც ცვლადების JSON-ში არ არის, გამოტოვებულია" +"variable values that don't match their type (or composed colours) were skipped" = "ცვლადის მნიშვნელობები, რომლებიც თავიანთ ტიპს არ შეესაბამება (ან შედგენილი ფერები), გამოტოვებულია" +"variables of unknown types were skipped" = "უცნობი ტიპის ცვლადები გამოტოვებულია" +"variables whose collection isn't in the variables JSON were skipped" = "ცვლადები, რომელთა კოლექცია ცვლადების JSON-ში არ არის, გამოტოვებულია" +"variables without readable values were skipped" = "წაკითხვადი მნიშვნელობების გარეშე ცვლადები გამოტოვებულია" +"vector points with unknown stroke joins use the layer join" = "უცნობი კონტურის შეერთების მქონე ვექტორული წერტილები შრის შეერთებას იყენებს" +"vectors without geometry are empty" = "გეომეტრიის გარეშე ვექტორები ცარიელია" +"vectors without geometry are empty (export the file JSON with geometry=paths)" = "გეომეტრიის გარეშე ვექტორები ცარიელია (ფაილის JSON-ის ექსპორტი გააკეთეთ geometry=paths პარამეტრით)" +"very long text was cut to 1,048,576 characters" = "ძალიან გრძელი ტექსტი 1 048 576 სიმბოლომდე შემოკლდა" +"very long vector paths were skipped" = "ძალიან გრძელი ვექტორული ტრაექტორიები გამოტოვებულია" +"video actions are not supported and were skipped" = "ვიდეოს მოქმედებები მხარდაუჭერელია და გამოტოვებულია" +"video triggers are not supported and were skipped" = "ვიდეოს გამშვებები მხარდაუჭერელია და გამოტოვებულია" +"yesterday" = "გუშინ" +"{instances} in {files}" = "{instances} — {files}" +"{language}: draft translation, waiting for a native speaker's review." = "{language}: თარგმანის მონახაზი, ელოდება მშობლიური ენის მცოდნის განხილვას." +"{length} bleed" = "ჭრისპირა არე: {length}" +"{month} {day}, {year}" = "{day} {month}, {year}" +"{name} cleared from the list" = "{name} ამოიშალა სიიდან" +"{name} is forgotten on this computer; the file stays on {server}" = "{name} დავიწყებულია ამ კომპიუტერზე; ფაილი {server}-ზე რჩება" +"{name} is on the server." = "{name} სერვერზეა." +"{name} is spotlighting" = "ყურადღება {name}-ზეა" +"{name}\nClick to apply to the selection" = "{name}\nდაწკაპეთ მონიშნულზე გამოსაყენებლად" +"{name}\nDrag onto the canvas, or click to place in the middle of the view.\nHold Alt (Option) while dropping onto an instance to swap it." = "{name}\nგადაათრიეთ ტილოზე ან დაწკაპეთ ხედის შუაში განსათავსებლად.\nინსტანსის ჩასანაცვლებლად მასზე ჩაგდებისას დააჭირეთ Alt-ს (Option)." +"{name} (from {library})" = "{name} ({library}-იდან)" +"{name} (you)" = "{name} (თქვენ)" +"{name} ({layers} layers)" = "{name} (შრე: {layers})" +"{name} ({tag}): {min} – {max}, default {default}. Double-click the slider to go back to the style's value; Escape while dragging cancels." = "{name} ({tag}): {min} – {max}, ნაგულისხმევი {default}. სტილის მნიშვნელობაზე დასაბრუნებლად ორჯერ დაწკაპეთ სლაიდერზე; Escape გადათრევისას აუქმებს." +"{name} from {path}" = "{name} — {path}" +"{name} was removed from the server" = "{name} წაიშალა სერვერიდან" +"{name} · deleted on main" = "{name} · წაშლილია მთავარში" +"{name} · deleted on the branch" = "{name} · წაშლილია განშტოებაში" +"{name} · not published" = "{name} · არ არის გამოქვეყნებული" +"{name} · on a server" = "{name} · სერვერზე" +"{name} · published version {v}" = "{name} · გამოქვეყნებული ვერსია {v}" +"{name} · version {v} on the server" = "{name} · ვერსია {v} სერვერზე" +"{name}, in a live session" = "{name}, ცოცხალ სესიაში" +"{name}, online, open only to you" = "{name}, ონლაინ, ღიაა მხოლოდ თქვენთვის" +"{name}, {kind}, {w} by {h}, selected" = "{name}, {kind}, {w} × {h}, მონიშნულია" +"{n} added" = { one = "დამატებულია: {n}", other = "დამატებულია: {n}" } +"{n} autosave versions" = { one = "ავტოშენახული ვერსია: {n}", other = "ავტოშენახული ვერსია: {n}" } +"{n} changed" = { one = "შეცვლილია: {n}", other = "შეცვლილია: {n}" } +"{n} columns" = { one = "სვეტი: {n}", other = "სვეტი: {n}" } +"{n} components" = { one = "კომპონენტი: {n}", other = "კომპონენტი: {n}" } +"{n} components and styles have newer versions" = { one = "ახალი ვერსია აქვს კომპონენტს ან სტილს: {n}", other = "ახალი ვერსია აქვს კომპონენტს ან სტილს: {n}" } +"{n} conflicts to resolve" = { one = "გადასაწყვეტი კონფლიქტი: {n}", other = "გადასაწყვეტი კონფლიქტი: {n}" } +"{n} days ago" = { one = "{n} დღის წინ", other = "{n} დღის წინ" } +"{n} files" = { one = "ფაილი: {n}", other = "ფაილი: {n}" } +"{n} hours ago" = { one = "{n} საათის წინ", other = "{n} საათის წინ" } +"{n} instances" = { one = "ინსტანსი: {n}", other = "ინსტანსი: {n}" } +"{n} layers" = { one = "შრე: {n}", other = "შრე: {n}" } +"{n} layers as one file" = { one = "შრე: {n}, ერთ ფაილად", other = "შრე: {n}, ერთ ფაილად" } +"{n} layers selected" = { one = "მონიშნული შრე: {n}", other = "მონიშნული შრე: {n}" } +"{n} layers · {parts}" = { one = "შრე: {n} · {parts}", other = "შრე: {n} · {parts}" } +"{n} matches" = { one = "დამთხვევა: {n}", other = "დამთხვევა: {n}" } +"{n} minutes ago" = { one = "{n} წუთის წინ", other = "{n} წუთის წინ" } +"{n} months ago" = { one = "{n} თვის წინ", other = "{n} თვის წინ" } +"{n} more notes omitted from this report" = { one = "ამ ანგარიშიდან გამოტოვებული სხვა შენიშვნები: {n}", other = "ამ ანგარიშიდან გამოტოვებული სხვა შენიშვნები: {n}" } +"{n} of {total}" = "{n} / {total}" +"{n} people joined" = { one = "შემოუერთდა ადამიანი: {n}", other = "შემოუერთდა ადამიანი: {n}" } +"{n} preferred components" = { one = "სასურველი კომპონენტი: {n}", other = "სასურველი კომპონენტი: {n}" } +"{n} ready" = { one = "მზადაა: {n}", other = "მზადაა: {n}" } +"{n} removed" = { one = "წაშლილია: {n}", other = "წაშლილია: {n}" } +"{n} replies" = { one = "პასუხი: {n}", other = "პასუხი: {n}" } +"{n} rows" = { one = "სტრიქონი: {n}", other = "სტრიქონი: {n}" } +"{n} styles" = { one = "სტილი: {n}", other = "სტილი: {n}" } +"{n} text layers" = { one = "ტექსტის შრე: {n}", other = "ტექსტის შრე: {n}" } +"{n} variables" = { one = "ცვლადი: {n}", other = "ცვლადი: {n}" } +"{n} weeks ago" = { one = "{n} კვირის წინ", other = "{n} კვირის წინ" } +"{n} years ago" = { one = "{n} წლის წინ", other = "{n} წლის წინ" } +"{n}d" = "{n} დღ" +"{n}h" = "{n} სთ" +"{n}m" = "{n} წთ" +"{n}w" = "{n} კვ" +"{page}, nothing selected" = "{page}, არაფერია მონიშნული" +"{profile} (current)" = "{profile} (მიმდინარე)" +"{property} is its value; {property ? \"a\" : \"b\"} picks a string. Other {…} stays as written." = "{property} მისი მნიშვნელობაა; {property ? \"a\" : \"b\"} ირჩევს სტრიქონს. ნებისმიერი სხვა {…} რჩება დაწერილი სახით." +"{server} can't say what became of your files (it is older than this app)." = "{server} ვერ გეტყვით, რა მოუვიდა თქვენს ფაილებს (ის ამ აპზე ძველია)." +"{server} · No longer on the server" = "{server} · სერვერზე აღარ არის" +"{server} · {shared} · Opened {date}" = "{server} · {shared} · გაიხსნა {date}" +"{tool} hold {key}" = "{tool} დააჭირეთ {key}" +"{value} isn't a colour" = "{value} ფერი არ არის" +"{when} · edited" = "{when} · რედაქტირებულია" +"← ↑ → ↓" = "← ↑ → ↓" +"← → ↑ ↓" = "← → ↑ ↓" +"←" = "←" +"→, Space or N" = "→, Space ან N" +"↵" = "↵" +"⇥ / ⇧⇥" = "⇥ / ⇧⇥" +"⌘ arrows" = "⌘ ისრები" +"⌘ click" = "⌘ დაწკაპუნება" +"⌘ scroll" = "⌘ გადახვევა" +"⌥ drag" = "⌥ გადათრევა" + +[commands] +"align.bottom" = "ქვემოთ სწორება" +"align.hcenter" = "ჰორიზონტალური ცენტრების სწორება" +"align.left" = "მარცხნივ სწორება" +"align.right" = "მარჯვნივ სწორება" +"align.top" = "ზემოთ სწორება" +"align.vcenter" = "ვერტიკალური ცენტრების სწორება" +"annotation.add" = "ანოტაციის დამატება" +"annotation.addCategory" = "ანოტაციის კატეგორიის დამატება" +"annotation.categories" = "ანოტაციის კატეგორიები" +"annotation.edit" = "ანოტაციის რედაქტირება" +"annotation.editCategory" = "ანოტაციის კატეგორიის რედაქტირება" +"annotation.list" = "ანოტაციების სია" +"annotation.remove" = "ანოტაციის წაშლა" +"annotation.removeCategory" = "ანოტაციის კატეგორიის წაშლა" +"app.actions" = "მოქმედებები" +"app.preferences" = "პარამეტრები" +"arrange.back" = "უკანა პლანზე გადატანა" +"arrange.backward" = "უკან გადატანა" +"arrange.forward" = "წინ გადმოტანა" +"arrange.front" = "წინა პლანზე გადმოტანა" +"arrange.packH" = "ჰორიზონტალურად შეკვრა" +"arrange.packV" = "ვერტიკალურად შეკვრა" +"arrange.roundToPixels" = "პიქსელებამდე დამრგვალება" +"arrange.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" = "მონიშნულის გადაკვეთა" +"bool.subtract" = "მონიშნულის გამოკლება" +"bool.union" = "მონიშნულის გაერთიანება" +"branch.archive" = "განშტოების დაარქივება" +"branch.create" = "განშტოების შექმნა" +"branch.list" = "განშტოებების სია" +"branch.merge" = "განშტოების შერწყმა" +"branch.open" = "განშტოების გახსნა" +"branch.review" = "განშტოების განხილვა" +"branch.updateFromMain" = "მთავარიდან განახლება" +"codeConnect.export" = "კოდთან კავშირების ექსპორტირება" +"codeConnect.import" = "კოდთან კავშირების იმპორტი" +"codeConnect.remove" = "კოდთან კავშირის გაწყვეტა" +"codeConnect.set" = "კოდთან დაკავშირება" +"collab.cursor" = "კურსორის ჩვენება მდებარეობაზე" +"collab.cursorChat" = "კურსორის ჩატი" +"collab.diff" = "სინქრონიზებული ცვლილებების ჩვენება" +"collab.files" = "თქვენი ონლაინ ფაილები" +"collab.follow" = "მიყოლა" +"collab.forgetFile" = "ონლაინ ფაილის დავიწყება" +"collab.host" = "ცოცხალი სესიის დაწყება" +"collab.join" = "ცოცხალ სესიასთან შეერთება" +"collab.leave" = "ცოცხალი სესიიდან გასვლა" +"collab.manageFile" = "გამოქვეყნებული ფაილის მართვა" +"collab.openFile" = "ონლაინ ფაილის გახსნა" +"collab.peers" = "ამ ფაილში მყოფი ადამიანები" +"collab.publish" = "სერვერზე გამოქვეყნება" +"collab.recoverFile" = "ონლაინ ფაილის აღდგენა" +"collab.registerLinks" = "მოსაწვევი ბმულების FigCraft-ში გახსნა" +"collab.removePerson" = "სესიიდან მოცილება" +"collab.report" = "ფაილზე საჩივარი სერვერის ოპერატორთან" +"collab.serverInfo" = "სერვერის ინფორმაცია" +"collab.share" = "შეერთების კოდით გაზიარება" +"collab.spotlight" = "ჩემზე ყურადღების გამახვილება" +"collab.stop" = "ცოცხალი სესიის შეწყვეტა" +"command.batch" = "რამდენიმე ბრძანების გაშვება" +"comment.add" = "კომენტარის დამატება" +"comment.delete" = "კომენტარის წაშლა" +"comment.edit" = "კომენტარის რედაქტირება" +"comment.link" = "კომენტარის ბმულის კოპირება" +"comment.list" = "კომენტარების სია" +"comment.markRead" = "კომენტარის წაკითხულად მარკირება" +"comment.move" = "კომენტარის გადატანა" +"comment.next" = "შემდეგი კომენტარი" +"comment.open" = "კომენტარის გახსნა" +"comment.people" = "კომენტარებში მონაწილე ადამიანები" +"comment.react" = "კომენტარზე რეაგირება" +"comment.reply" = "კომენტარზე პასუხი" +"comment.resolve" = "კომენტარის გადაწყვეტა" +"comment.zoom" = "კომენტარების მასშტაბირება" +"component.addProperty" = "კომპონენტის თვისების დამატება" +"component.addVariant" = "ვარიანტის დამატება" +"component.bindProperty" = "შრის თვისებაზე მიბმა" +"component.combineVariants" = "ვარიანტებად გაერთიანება" +"component.create" = "კომპონენტის შექმნა" +"component.createMultiple" = "რამდენიმე კომპონენტის შექმნა" +"component.deleteProperty" = "კომპონენტის თვისების წაშლა" +"component.detach" = "ინსტანსის გამოყოფა" +"component.editProperty" = "კომპონენტის თვისების რედაქტირება" +"component.exposeInstance" = "ჩადგმული ინსტანსის გამოტანა" +"component.goToMain" = "მთავარ კომპონენტზე გადასვლა" +"component.hideWhenPublishing" = "გამოქვეყნებისას დამალვა" +"component.list" = "კომპონენტების სია" +"component.makeSlot" = "სლოტად გადაქცევა" +"component.playground" = "კომპონენტის სავარჯიშო მოედანი" +"component.properties" = "კომპონენტის თვისებები" +"component.pushChanges" = "ცვლილებების მთავარ კომპონენტში გადაგზავნა" +"component.removeSlot" = "სლოტის მოხსნა" +"component.resetInstance" = "ყველა ცვლილების ჩამოყრა" +"component.resetOverride" = "გადაფარვის ჩამოყრა" +"component.restoreMain" = "მთავარი კომპონენტის აღდგენა" +"dev.compare" = "ცვლილებების შედარება" +"dev.focus" = "ფოკუსის ხედი" +"dev.resolveChange" = "ცვლილების გადაწყვეტა" +"dev.unfocus" = "ფოკუსის ხედიდან გასვლა" +"devResource.add" = "დეველოპერის რესურსის დამატება" +"devResource.edit" = "დეველოპერის რესურსის რედაქტირება" +"devResource.list" = "დეველოპერის რესურსების სია" +"devResource.remove" = "დეველოპერის რესურსის წაშლა" +"distribute.horizontal" = "ჰორიზონტალური შუალედების განაწილება" +"distribute.vertical" = "ვერტიკალური შუალედების განაწილება" +"doc.home" = "საწყისი ეკრანი" +"doc.list" = "დოკუმენტების სია" +"doc.nextTab" = "შემდეგი ჩანართი" +"doc.previousTab" = "წინა ჩანართი" +"doc.switch" = "დოკუმენტის გადართვა" +"edit.copy" = "კოპირება" +"edit.copyAsCss" = "კოპირება როგორც CSS" +"edit.copyAsPng" = "კოპირება როგორც PNG" +"edit.copyAsSvg" = "კოპირება როგორც SVG" +"edit.copyAsText" = "კოპირება როგორც ტექსტი" +"edit.copyLink" = "მონიშნულის ბმულის კოპირება" +"edit.copyProperties" = "თვისებების კოპირება" +"edit.cut" = "ამოჭრა" +"edit.delete" = "წაშლა" +"edit.duplicate" = "დუბლირება" +"edit.findReplace" = "ძებნა და ჩანაცვლება" +"edit.paste" = "ჩასმა" +"edit.pasteDesignLayers" = "დიზაინის შრეების ჩასმა" +"edit.pasteOver" = "მონიშნულზე ჩასმა" +"edit.pasteProperties" = "თვისებების ჩასმა" +"edit.pasteReplace" = "ჩასმა ჩასანაცვლებლად" +"edit.pickColor" = "ფერის არჩევა" +"edit.redo" = "ხელახლა შესრულება" +"edit.repeat" = "ბოლო ბრძანების გამეორება" +"edit.setDefaultProperties" = "ნაგულისხმევი თვისებების დაყენება" +"edit.undo" = "დაბრუნება" +"effect.add" = "ეფექტის დამატება" +"effect.move" = "ეფექტის გადატანა" +"effect.remove" = "ეფექტის წაშლა" +"effect.set" = "ეფექტის დაყენება" +"export.add" = "ექსპორტის პარამეტრის დამატება" +"export.remove" = "ექსპორტის პარამეტრის წაშლა" +"export.update" = "ექსპორტის პარამეტრის რედაქტირება" +"file.close" = "ჩანართის დახურვა" +"file.colorProfile" = "ფერის პროფილი" +"file.duplicate" = "ფაილის დუბლირება" +"file.export" = "ექსპორტირება" +"file.exportFramesPdf" = "ჩარჩოების PDF-ად ექსპორტირება" +"file.exportLayers" = "შრეების ექსპორტირება" +"file.exportSvg" = "მონიშნულის SVG-ად ექსპორტირება" +"file.import" = "იმპორტი" +"file.new" = "ახალი დიზაინის ფაილი" +"file.newBoard" = "ახალი დაფა" +"file.newFromTemplate" = "შაბლონიდან შექმნა" +"file.open" = "გახსნა" +"file.openRecent" = "ბოლოს გახსნილი ფაილების გახსნა" +"file.placeImage" = "გამოსახულების განთავსება" +"file.recover" = "ფაილის აღდგენა" +"file.rename" = "ფაილის სახელის შეცვლა" +"file.revert" = "წინა ვერსიაზე დაბრუნება" +"file.save" = "შენახვა" +"file.saveAs" = "შენახვა როგორც" +"file.saveVersion" = "ვერსიების ისტორიაში შენახვა" +"file.versionHistory" = "ვერსიების ისტორიის ჩვენება" +"fill.add" = "შევსების დამატება" +"fill.move" = "შევსების გადატანა" +"fill.pattern" = "პატერნით შევსება" +"fill.remove" = "შევსების წაშლა" +"fill.set" = "შევსების დაყენება" +"frame.create" = "ჩარჩოს შექმნა" +"frame.presets" = "ჩარჩოს პრესეტების სია" +"frame.swapOrientation" = "ორიენტაციის შეცვლა" +"grid.add" = "განლაგების ბადის დამატება" +"grid.move" = "განლაგების ბადის გადატანა" +"grid.remove" = "განლაგების ბადის წაშლა" +"guide.add" = "დამხმარე ხაზის დამატება" +"guide.clear" = "ყველა დამხმარე ხაზის წაშლა" +"guide.move" = "დამხმარე ხაზის გადაადგილება" +"guide.remove" = "დამხმარე ხაზის მოცილება" +"guide.select" = "დამხმარე ხაზის მონიშვნა" +"help.about" = "FigCraft-ის შესახებ" +"help.gettingStarted" = "პირველი ნაბიჯები" +"help.page" = "დახმარების გვერდი" +"help.shortcuts" = "კლავიატურის მალსახმობები" +"image.adjust" = "გამოსახულების კორექცია" +"image.deletePreset" = "გამოსახულების პრესეტის წაშლა" +"image.place" = "გამოსახულების განთავსება" +"image.presets" = "გამოსახულების პრესეტები" +"image.savePreset" = "გამოსახულების პრესეტის შენახვა" +"inspect.assets" = "რესურსების ინსპექტირება" +"inspect.code" = "შრის კოდი" +"inspect.componentCode" = "კომპონენტის კოდი" +"inspect.properties" = "თვისებების ინსპექტირება" +"inspect.readyForDev" = "მზადაა დეველოპერისთვის" +"instance.create" = "ინსტანსის შექმნა" +"instance.exposed" = "გამოტანილი ჩადგმული ინსტანსები" +"instance.place" = "ინსტანსის განთავსება" +"instance.resetSlot" = "სლოტის ჩამოყრა" +"instance.setProperties" = "ინსტანსის თვისებების დაყენება" +"instance.slotInsert" = "სლოტში ჩასმა" +"instance.swap" = "ინსტანსის ჩანაცვლება" +"instance.swapAt" = "ინსტანსის ჩანაცვლება წერტილში" +"layers.collapseAll" = "შრეების ჩაკეცვა" +"layout.add" = "ავტოგანლაგების დამატება" +"layout.remove" = "ავტოგანლაგების მოხსნა" +"layout.setTrack" = "ბადის ტრეკის ზომის დაყენება" +"layout.suggest" = "ავტოგანლაგების შეთავაზება" +"libraries.open" = "ბიბლიოთეკები" +"libraries.publish" = "ბიბლიოთეკის გამოქვეყნება…" +"library.acceptUpdates" = "ბიბლიოთეკის განახლებების მიღება" +"library.add" = "ბიბლიოთეკის დამატება" +"library.aliasVariable" = "ბიბლიოთეკის ცვლადის ალიასი" +"library.applyStyle" = "ბიბლიოთეკის სტილის გამოყენება" +"library.bindVariable" = "ბიბლიოთეკის ცვლადის მიბმა" +"library.extendCollection" = "ბიბლიოთეკის კოლექციის გაფართოება" +"library.import" = "ბიბლიოთეკის კომპონენტის იმპორტი" +"library.importVariable" = "ბიბლიოთეკის ცვლადის იმპორტი" +"library.list" = "ბიბლიოთეკების სია" +"library.place" = "ბიბლიოთეკის კომპონენტის განთავსება" +"library.publish" = "ბიბლიოთეკის გამოქვეყნება" +"library.publishToServer" = "ბიბლიოთეკის სერვერზე გამოქვეყნება" +"library.remove" = "ბიბლიოთეკის წაშლა" +"library.setVariableMode" = "ბიბლიოთეკის ცვლადის რეჟიმის დაყენება" +"library.swap" = "ბიბლიოთეკის ჩანაცვლება" +"library.swapAt" = "ინსტანსის ბიბლიოთეკის კომპონენტით ჩანაცვლება" +"library.updates" = "ბიბლიოთეკის განახლებები" +"library.usage" = "ბიბლიოთეკის გამოყენება" +"measure.distances" = "მანძილების გაზომვა" +"measure.padding" = "ავტოგანლაგების შიდა დაშორების გაზომვა" +"node.create" = "შრის შექმნა" +"node.get" = "შრის მიღება" +"node.reorder" = "შრის გადალაგება" +"node.reparent" = "შრის შიგნით გადატანა" +"node.set" = "თვისებების დაყენება" +"object.batchRename" = "ჯგუფური სახელის შეცვლა" +"object.convertToFrame" = "ჩარჩოდ გარდაქმნა" +"object.convertToGroup" = "ჯგუფად გარდაქმნა" +"object.convertToSection" = "სექციად გარდაქმნა" +"object.flipH" = "ჰორიზონტალურად არეკვლა" +"object.flipV" = "ვერტიკალურად არეკვლა" +"object.frameSelection" = "მონიშნულის ჩარჩოში მოქცევა" +"object.group" = "ჯგუფის მონიშვნა" +"object.hideOthers" = "სხვა შრეების დამალვა" +"object.mask" = "ნიღბად გამოყენება" +"object.moveToPage" = "გვერდზე გადატანა" +"object.opacityKeys" = "გაუმჭვირვალობა ციფრული კლავიშებით" +"object.rasterize" = "მონიშნულის რასტრირება" +"object.releaseMask" = "ნიღბის მოხსნა" +"object.removeFill" = "შევსების წაშლა" +"object.removeFrame" = "ჩარჩოს მოხსნა" +"object.removeStroke" = "კონტურის წაშლა" +"object.rename" = "სახელის შეცვლა" +"object.resizeToFit" = "შიგთავსზე ზომის მორგება" +"object.rotate180" = "180°-ით შემობრუნება" +"object.rotateLeft" = "90°-ით მარცხნივ შემობრუნება" +"object.rotateRight" = "90°-ით მარჯვნივ შემობრუნება" +"object.setThumbnail" = "ესკიზად დაყენება" +"object.swapFillStroke" = "შევსებისა და კონტურის გაცვლა" +"object.toggleLock" = "მონიშნულის ჩაკეტვა/განბლოკვა" +"object.toggleVisible" = "მონიშნულის ჩვენება/დამალვა" +"object.ungroup" = "მონიშნულის დაჯგუფების მოხსნა" +"object.wrapInSection" = "ახალ სექციაში მოქცევა" +"page.add" = "გვერდის დამატება" +"page.delete" = "გვერდის წაშლა" +"page.duplicate" = "გვერდის დუბლირება" +"page.list" = "გვერდების სია" +"page.move" = "გვერდის გადატანა" +"page.rename" = "გვერდის სახელის შეცვლა" +"page.select" = "გვერდზე გადასვლა" +"page.setBackground" = "გვერდის ფონი" +"paint.documentColors" = "დოკუმენტის ფერები" +"plugin.close" = "პლაგინის პანელის დახურვა" +"plugin.directory.install" = "პლაგინის ინსტალაცია კატალოგიდან" +"plugin.directory.list" = "პლაგინების კატალოგის დათვალიერება" +"plugin.directory.publish" = "პლაგინის კატალოგში გამოქვეყნება" +"plugin.directory.unpublish" = "პლაგინის კატალოგიდან მოხსნა" +"plugin.event" = "პლაგინის პანელის მართვის ელემენტის გამოყენება" +"plugin.import" = "პლაგინის იმპორტი მანიფესტიდან…" +"plugin.list" = "პლაგინების სია" +"plugin.remove" = "პლაგინის წაშლა" +"plugin.run" = "პლაგინის გაშვება" +"plugin.ui" = "პლაგინის პანელი" +"plugins.manage" = "პლაგინების მართვა…" +"plugins.runLast" = "ბოლო პლაგინის გაშვება" +"pref.autoSave" = "ფაილების ავტომატურად შენახვა" +"pref.highlightHover" = "შრეების გამოკვეთა მაჩვენებლის მიტანისას" +"pref.keepTool" = "ხელსაწყოს არჩეულად დატოვება გამოყენების შემდეგ" +"pref.language" = "ინტერფეისის ენა" +"pref.nudge" = "მცირედ გადაწევის ბიჯი" +"pref.numberKeysOpacity" = "გაუმჭვირვალობისთვის ციფრული კლავიშების გამოყენება" +"pref.publishServer" = "ონლაინ გაზიარების სერვერი" +"pref.reduceMotion" = "მოძრაობის შემცირება" +"pref.renameDuplicates" = "დუბლირებული შრეების სახელის შეცვლა" +"pref.showDimensions" = "ზომების ჩვენება ობიექტებზე" +"pref.userName" = "თქვენი სახელი კომენტარებში" +"proto.action.add" = "პროტოტიპის მოქმედების დამატება" +"proto.action.move" = "პროტოტიპის მოქმედების გადალაგება" +"proto.action.remove" = "პროტოტიპის მოქმედების წაშლა" +"proto.action.set" = "პროტოტიპის მოქმედების რედაქტირება" +"proto.closePreview" = "პროტოტიპის წინასწარი ნახვის დახურვა" +"proto.conditional.addBlock" = "„სხვა შემთხვევაში, თუ“ / „სხვა შემთხვევაში“-ს დამატება" +"proto.conditional.removeBlock" = "„თუ“ / „სხვა შემთხვევაში, თუ“ / „სხვა შემთხვევაში“-ს წაშლა" +"proto.conditional.setCondition" = "პირობის რედაქტირება" +"proto.evaluate" = "პროტოტიპის ექსპრესიის გამოთვლა" +"proto.flow.add" = "ნაკადის საწყისი წერტილის დამატება" +"proto.flow.remove" = "ნაკადის წაშლა" +"proto.flow.rename" = "ნაკადის სახელის შეცვლა" +"proto.interaction.add" = "ურთიერთქმედების დამატება" +"proto.interaction.clear" = "ყველა ურთიერთქმედების წაშლა" +"proto.interaction.remove" = "ურთიერთქმედების წაშლა" +"proto.interaction.set" = "ურთიერთქმედების რედაქტირება" +"proto.present" = "წარდგენა" +"proto.presentOptions" = "წარდგენის პარამეტრები" +"proto.preview" = "პროტოტიპის წინასწარი ნახვის გახსნა" +"proto.settings" = "პროტოტიპის პარამეტრები" +"section.toggleContents" = "სექციის შიგთავსის დამალვა/ჩვენება" +"select.add" = "მონიშნულზე დამატება" +"select.all" = "ყველაფრის მონიშვნა" +"select.area" = "არის მონიშვნა" +"select.byName" = "შრეების სახელით მონიშვნა" +"select.children" = "შვილობილების მონიშვნა" +"select.inverse" = "მონიშვნის შებრუნება" +"select.layersAt" = "შრეები წერტილში" +"select.matching" = "შესაბამისი შრეების მონიშვნა" +"select.next" = "შემდეგი მეზობლის მონიშვნა" +"select.none" = "მონიშვნის მოხსნა" +"select.parent" = "მშობლის მონიშვნა" +"select.previous" = "წინა მეზობლის მონიშვნა" +"select.remove" = "მონიშნულიდან ამოღება" +"select.sameEffect" = "იგივე ეფექტის მონიშვნა" +"select.sameFill" = "იგივე შევსების მონიშვნა" +"select.sameFont" = "იგივე შრიფტის მონიშვნა" +"select.sameInstance" = "იგივე ინსტანსის მონიშვნა" +"select.sameProperties" = "იგივე თვისებების მონიშვნა" +"select.sameStroke" = "იგივე კონტურის მონიშვნა" +"select.sameText" = "ტექსტის იგივე თვისებების მონიშვნა" +"select.set" = "მონიშვნა" +"selection.applyColorStyle" = "მონიშნულის ფერის სტილის გამოყენება" +"selection.colors" = "მონიშნულის ფერები" +"selection.selectColor" = "ამ ფერის შრეების მონიშვნა" +"selection.setPaint" = "მონიშნულის ფერის შეცვლა" +"shape.arrow" = "ისარი" +"shape.ellipse" = "ელიფსი" +"shape.line" = "ხაზი" +"shape.polygon" = "მრავალკუთხედი" +"shape.rectangle" = "მართკუთხედი" +"shape.star" = "ვარსკვლავი" +"shapeBuilder.merge" = "არეების შერწყმა" +"shapeBuilder.mode" = "ფიგურების კონსტრუქტორის რეჟიმი" +"shapeBuilder.regions" = "ფიგურების კონსტრუქტორის არეები" +"shapeBuilder.remove" = "არეების წაშლა" +"stroke.add" = "კონტურის დამატება" +"stroke.brush" = "ფუნჯი" +"stroke.move" = "კონტურის გადატანა" +"stroke.remove" = "კონტურის წაშლა" +"stroke.set" = "კონტურის დაყენება" +"stroke.widthProfile" = "სიგანის პროფილი" +"style.apply" = "სტილის გამოყენება" +"style.bind" = "ცვლადის სტილში მიბმა" +"style.create" = "სტილის შექმნა" +"style.delete" = "სტილის წაშლა" +"style.detach" = "სტილის გამოყოფა" +"style.duplicate" = "სტილის დუბლირება" +"style.list" = "სტილების სია" +"style.move" = "სტილების გადატანა" +"style.renameFolder" = "სტილების საქაღალდის სახელის შეცვლა" +"style.update" = "სტილის რედაქტირება" +"template.list" = "შაბლონების სია" +"text.alignCenter" = "ცენტრში სწორება" +"text.alignJustify" = "ორმხრივი სწორება" +"text.alignLeft" = "მარცხნივ სწორება" +"text.alignRight" = "მარჯვნივ სწორება" +"text.bold" = "მუქი" +"text.bulletList" = "ბურთულებიანი სია" +"text.case" = "ასოთა რეგისტრი" +"text.caseLower" = "პატარა ასოები" +"text.caseOriginal" = "საწყისი რეგისტრი" +"text.caseSmallCaps" = "პატარა მთავრული" +"text.caseTitle" = "სათაურის რეგისტრი" +"text.caseUpper" = "მთავრული" +"text.create" = "ტექსტი" +"text.decoration" = "გაფორმება" +"text.edit.begin" = "ტექსტის რედაქტირება" +"text.edit.caret" = "კურსორის დაყენება" +"text.edit.end" = "ტექსტის რედაქტირების დასრულება" +"text.edit.insert" = "ტექსტის აკრეფა" +"text.edit.key" = "ტექსტის კლავიში" +"text.edit.preedit" = "ტექსტის აწყობა (IME)" +"text.edit.selectLine" = "სტრიქონის მონიშვნა" +"text.edit.selectWord" = "სიტყვის მონიშვნა" +"text.edit.selection" = "ტექსტის მონიშვნა" +"text.endPreview" = "ტექსტის გადახედვის დასრულება" +"text.fonts" = "შრიფტების სია" +"text.italic" = "დახრილი" +"text.lineHeightDown" = "სტრიქონის სიმაღლის შემცირება" +"text.lineHeightUp" = "სტრიქონის სიმაღლის გაზრდა" +"text.link" = "ბმულის შექმნა" +"text.listIndent" = "სიის შეწევის გაზრდა" +"text.listOutdent" = "სიის შეწევის შემცირება" +"text.listStyle" = "სიის სტილი" +"text.maxLines" = "მაქს. სტრიქონები" +"text.missingFonts" = "მიუწვდომელი შრიფტები" +"text.numberList" = "დანომრილი სია" +"text.onPath" = "ტექსტი ტრაექტორიაზე" +"text.paragraphIndent" = "აბზაცის შეწევა" +"text.paragraphSpacing" = "აბზაცთა ინტერვალი" +"text.pathOptions" = "ტრაექტორიაზე ტექსტის პარამეტრები" +"text.preview" = "ტექსტის ცვლილების გადახედვა" +"text.removeFromPath" = "ტექსტის ტრაექტორიიდან მოხსნა" +"text.set" = "ტექსტის დაყენება" +"text.setFeatures" = "OpenType შესაძლებლობები" +"text.setVariations" = "ცვლადი შრიფტის ღერძები" +"text.sizeDown" = "შრიფტის ზომის შემცირება" +"text.sizeUp" = "შრიფტის ზომის გაზრდა" +"text.strikethrough" = "გადახაზული" +"text.style" = "ტექსტის დიაპაზონის სტილის მინიჭება" +"text.trackingDown" = "ასოებს შორის დაშორების შემცირება" +"text.trackingUp" = "ასოებს შორის დაშორების გაზრდა" +"text.underline" = "ხაზგასმული" +"text.weightDown" = "შრიფტის სისქის შემცირება" +"text.weightUp" = "შრიფტის სისქის გაზრდა" +"tool.arrow" = "ისარი" +"tool.comment" = "კომენტარი" +"tool.connector" = "შემაერთებელი" +"tool.ellipse" = "ელიფსი" +"tool.eraser" = "საშლელი" +"tool.frame" = "ჩარჩო" +"tool.hand" = "ხელი" +"tool.highlighter" = "ხაზგასმის მარკერი" +"tool.line" = "ხაზი" +"tool.marker" = "მარკერი" +"tool.move" = "გადაადგილება" +"tool.pen" = "კალამი" +"tool.pencil" = "ფანქარი" +"tool.pencilBrush" = "ფანქრის ფუნჯი" +"tool.place" = "ხელსაწყოთი განთავსება" +"tool.polygon" = "მრავალკუთხედი" +"tool.rectangle" = "მართკუთხედი" +"tool.scale" = "მასშტაბირება" +"tool.scaleAnchor" = "მასშტაბირების საყრდენი წერტილი" +"tool.section" = "სექცია" +"tool.set" = "ხელსაწყოს არჩევა" +"tool.shape" = "ფიგურები" +"tool.shapeBuilder" = "ფიგურების კონსტრუქტორი" +"tool.slice" = "ნაჭერი" +"tool.stamp" = "შტამპი" +"tool.star" = "ვარსკვლავი" +"tool.sticky" = "სტიკერი" +"tool.text" = "ტექსტი" +"transform.move" = "გადაადგილება" +"transform.moveTo" = "გადატანა" +"transform.nudge" = "მცირედ გადაწევა" +"transform.nudgeSize" = "ზომის შეცვლა მცირედ გადაწევით" +"transform.resize" = "ზომის შეცვლა" +"transform.rotate" = "შემობრუნება" +"transform.rotationOrigin" = "შემობრუნების ცენტრის გადატანა" +"transform.scale" = "მასშტაბირება" +"transform.setRotationOrigin" = "შემობრუნების ცენტრის დაყენება" +"variable.addMode" = "რეჟიმის დამატება" +"variable.bind" = "ცვლადის მიბმა" +"variable.create" = "ცვლადის შექმნა" +"variable.createCollection" = "ცვლადების კოლექციის შექმნა" +"variable.delete" = "ცვლადის წაშლა" +"variable.deleteCollection" = "კოლექციის წაშლა" +"variable.duplicate" = "ცვლადის დუბლირება" +"variable.export" = "ცვლადების ექსპორტირება" +"variable.import" = "ცვლადების იმპორტი" +"variable.list" = "ცვლადების სია" +"variable.moveMode" = "რეჟიმის გადატანა" +"variable.removeMode" = "რეჟიმის წაშლა" +"variable.renameCollection" = "კოლექციის სახელის შეცვლა" +"variable.renameMode" = "რეჟიმის სახელის შეცვლა" +"variable.resetOverride" = "ცვლადის გადაფარვის ჩამოყრა" +"variable.set" = "ცვლადის მნიშვნელობის დაყენება" +"variable.setMode" = "ცვლადის რეჟიმის დაყენება" +"variable.update" = "ცვლადის რედაქტირება" +"vector.create" = "ვექტორი" +"vector.deleteHeal" = "მონიშნულის წაშლა ხარვეზის შევსებით" +"vector.edit.addVertex" = "წერტილის დამატება" +"vector.edit.begin" = "ვექტორის რედაქტირება" +"vector.edit.bend" = "სეგმენტის მოხრა" +"vector.edit.bounds" = "მონიშნული წერტილების საზღვრები" +"vector.edit.connect" = "წერტილების შეერთება" +"vector.edit.cornerRadius" = "წერტილის კუთხის რადიუსი" +"vector.edit.cut" = "ტრაექტორიის გაჭრა" +"vector.edit.delete" = "წერტილების წაშლა" +"vector.edit.end" = "რედაქტირების დასრულება" +"vector.edit.erase" = "წაშლა" +"vector.edit.lasso" = "ლასოთი მონიშვნა" +"vector.edit.mirroring" = "სახელურების არეკვლა" +"vector.edit.moveHandles" = "სახელურების გადატანა" +"vector.edit.moveVertices" = "წერტილების გადატანა" +"vector.edit.nudge" = "წერტილების მცირედ გადაწევა" +"vector.edit.paint" = "საღებავის ვედრო" +"vector.edit.pen" = "კალმის წერტილი" +"vector.edit.penEnd" = "კალმის ტრაექტორიის დასრულება" +"vector.edit.position" = "წერტილის პოზიცია" +"vector.edit.resize" = "წერტილების ზომის შეცვლა" +"vector.edit.rotate" = "წერტილების შემობრუნება" +"vector.edit.select" = "წერტილების მონიშვნა" +"vector.edit.selectAll" = "ყველა წერტილის მონიშვნა" +"vector.edit.setHandle" = "სახელურის დაყენება" +"vector.edit.smooth" = "წერტილების მოგლუვება ან გამახვილება" +"vector.edit.state" = "ვექტორის რედაქტირების მდგომარეობა" +"vector.edit.strokeCap" = "კონტურის დაბოლოება წერტილში" +"vector.edit.strokeJoin" = "კონტურის შეერთება წერტილში" +"vector.edit.tool" = "ვექტორის რედაქტირების ხელსაწყო" +"vector.edit.transform" = "წერტილების გარდაქმნა" +"vector.edit.widthPoint" = "სიგანის წერტილი" +"vector.flatten" = "გაბრტყელება" +"vector.join" = "მონიშნულის შეერთება" +"vector.offset" = "ტრაექტორიის წანაცვლება" +"vector.outlineStroke" = "კონტურის ფიგურად გარდაქმნა" +"vector.reverse" = "ტრაექტორიის მიმართულების შებრუნება" +"vector.simplify" = "ვექტორის გამარტივება" +"vector.smoothJoin" = "მონიშნულის გლუვი შეერთება" +"version.autosave" = "ვერსიის ავტოშენახვა" +"version.delete" = "ვერსიის წაშლა" +"version.duplicate" = "ვერსიის დუბლირება" +"version.exitPreview" = "მიმდინარე ვერსიაზე დაბრუნება" +"version.list" = "ვერსიების სია" +"version.preview" = "ვერსიის ნახვა" +"version.rename" = "ვერსიის სახელის შეცვლა" +"version.restore" = "ვერსიის აღდგენა" +"view.assets" = "რესურსების პანელი" +"view.comments" = "კომენტარები" +"view.contextMenu" = "კონტექსტური მენიუს ჩვენება" +"view.design" = "დიზაინის პანელი" +"view.designPrototype" = "გადართვა დიზაინსა და პროტოტიპს შორის" +"view.devMode" = "დეველოპერის რეჟიმი" +"view.frameOutlines" = "ჩარჩოების კონტურები" +"view.fullscreen" = "სრული ეკრანი" +"view.get" = "ხედის მიღება" +"view.layers" = "შრეების პანელი" +"view.layoutGrids" = "განლაგების ბადეები" +"view.leftSidebar" = "მარცხენა გვერდითი ზოლის ჩვენება/დამალვა" +"view.maskOutlines" = "ნიღბების კონტურები" +"view.multiplayerCursors" = "სხვა მონაწილეების კურსორები" +"view.nextFrame" = "შემდეგი ჩარჩოს მასშტაბირება" +"view.nextPage" = "შემდეგი გვერდი" +"view.nextRegion" = "შემდეგ არეზე ფოკუსირება" +"view.outlines" = "კონტურები" +"view.outlinesHiddenLayers" = "კონტურებში დამალული შრეების ჩართვა" +"view.outlinesObjectBounds" = "კონტურებში ობიექტების საზღვრების ჩართვა" +"view.outlinesSelection" = "მონიშნულის კონტურები" +"view.pan" = "ხედის გადაადგილება" +"view.panelWidths" = "პანელების სიგანის ჩამოყრა" +"view.pixelGrid" = "პიქსელური ბადე" +"view.pixelPreview" = "პიქსელების წინასწარი ნახვა" +"view.prevFrame" = "წინა ჩარჩოს მასშტაბირება" +"view.prevPage" = "წინა გვერდი" +"view.previousRegion" = "წინა არეზე ფოკუსირება" +"view.proofSrgb" = "ეკრანზე sRGB-ის სიმულაცია" +"view.prototype" = "პროტოტიპის პანელი" +"view.rulers" = "სახაზავები" +"view.set" = "ხედის დაყენება" +"view.slices" = "ნაჭრები" +"view.snapGeometry" = "გეომეტრიაზე მიბმა" +"view.snapObjects" = "ობიექტებზე მიბმა" +"view.snapPixel" = "პიქსელურ ბადეზე მიბმა" +"view.themeDark" = "ბნელი თემა" +"view.themeLight" = "ღია თემა" +"view.themeSystem" = "სისტემის თემის მიყოლა" +"view.toggleUi" = "ინტერფეისის ჩვენება/დამალვა" +"view.zoom100" = "მასშტაბი 100%" +"view.zoom200" = "მასშტაბი 200%" +"view.zoom50" = "მასშტაბი 50%" +"view.zoomAt" = "მასშტაბის ხელსაწყო" +"view.zoomFit" = "ეკრანში ჩატევა" +"view.zoomIn" = "გადიდება" +"view.zoomOut" = "დაპატარავება" +"view.zoomSelection" = "მონიშნულის მასშტაბირება" +"widget.click" = "ვიჯეტზე დაწკაპუნება" +"widget.insert" = "ვიჯეტის ჩასმა" +"widget.list" = "ვიჯეტების სია" +"window.focus" = "ფანჯრის წინა პლანზე გადმოტანა" +"window.new" = "ახალი ფანჯარა" + +[menu] +"bool.exclude" = "გამორიცხვა" +"bool.flatten" = "გაბრტყელება" +"bool.intersect" = "გადაკვეთა" +"bool.subtract" = "გამოკლება" +"bool.union" = "გაერთიანება" +"file.duplicate" = "დუბლირება" +"file.import" = "ფაილიდან იმპორტი…" +"file.rename" = "სახელის შეცვლა" +"libraries.open" = "ბიბლიოთეკების გახსნა" +"select.sameEffect" = "ეფექტი" +"select.sameFill" = "შევსება" +"select.sameFont" = "შრიფტი" +"select.sameInstance" = "ინსტანსი" +"select.sameProperties" = "თვისებები" +"select.sameStroke" = "კონტური" +"select.sameText" = "ტექსტის თვისებები" +"text.alignCenter" = "ტექსტის ცენტრში სწორება" +"text.alignJustify" = "ტექსტის ორმხრივი სწორება" +"text.alignLeft" = "ტექსტის მარცხნივ სწორება" +"text.alignRight" = "ტექსტის მარჯვნივ სწორება" +"view.assets" = "რესურსები" +"view.design" = "დიზაინი" +"view.layers" = "შრეები" +"view.outlines" = "კონტურების ჩვენება" +"view.outlinesHiddenLayers" = "დამალული შრეების ჩართვა" +"view.outlinesObjectBounds" = "ობიექტების საზღვრების ჩართვა" +"view.outlinesSelection" = "მონიშნულის კონტურების ჩვენება" +"view.prototype" = "პროტოტიპი" +"view.themeDark" = "ბნელი" +"view.themeLight" = "ღია" +"view.themeSystem" = "სისტემის მიყოლა" diff --git a/crates/ui-egui/locales/ko.toml b/crates/ui-egui/locales/ko.toml index 3e191623..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" = "콘텐츠" @@ -305,6 +309,7 @@ status = "draft — needs native review" "Corner radius" = "모서리 반경" "Corner smoothing" = "모서리 스무딩" "Couldn't reach {server}: showing what this computer remembers." = "{server}에 연결할 수 없음: 이 컴퓨터가 기억하는 내용을 표시함." +"Couldn't read {name}." = "{name}을(를) 읽을 수 없습니다." "Couldn't save automatically: {error}" = "자동으로 저장할 수 없음: {error}" "Count" = "개수" "Create" = "만들기" @@ -359,6 +364,7 @@ status = "draft — needs native review" "Description" = "설명" "Description (optional)" = "설명(선택 사항)" "Design" = "디자인" +"Design panel" = "디자인 패널" "Desktop" = "데스크톱" "Destination" = "대상" "Detach alias" = "별칭 분리" @@ -369,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" = "기기" @@ -385,6 +392,7 @@ status = "draft — needs native review" "Distribute horizontal spacing" = "수평 간격 균등 분포" "Distribute vertical spacing" = "수직 간격 균등 분포" "Divider" = "구분선" +"Don't Save" = "저장 안 함" "Don't truncate" = "자르지 않음" "Done" = "완료" "Done editing" = "편집 완료" @@ -415,6 +423,7 @@ status = "draft — needs native review" "Effect settings" = "효과 설정" "Effect styles" = "효과 스타일" "Effects" = "효과" +"Elbow" = "꺾은선" "Ellipse" = "타원" "Ellipses" = "타원" "Else" = "그 외" @@ -545,6 +554,7 @@ status = "draft — needs native review" "Hanging list" = "내어쓰기 목록" "Hanging punctuation" = "문장 부호 내어쓰기" "Hard light" = "하드 라이트" +"Heart" = "하트" "Height" = "높이" "Help" = "도움말" "Help and resources" = "도움말 및 리소스" @@ -555,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 누른 채로" @@ -710,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)" = "일치(비우면 이름 전체)" @@ -766,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" = "포커스된 영역의 다음 또는 이전 컨트롤" @@ -828,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." = "아직 온라인에 올린 파일이 없음. 파일에서 공유를 사용해 온라인에 올리면 여기에 표시됨." @@ -861,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에서 열기" @@ -892,8 +907,10 @@ status = "draft — needs native review" "PDF print settings" = "PDF 인쇄 설정" "Padding" = "패딩" "Page" = "페이지" +"Page deleted" = "페이지가 삭제됨" "Pages" = "페이지" "Pages added to the document" = "문서에 페이지 추가됨" +"Pink" = "분홍" "Placed on the current page" = "현재 페이지에 배치됨" "Pages: {pages}" = "페이지: {pages}" "Paint bucket" = "페인트 통" @@ -935,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" = "다각형" @@ -981,6 +999,7 @@ status = "draft — needs native review" "Push" = "밀어내기" "Push changes to main component" = "변경 사항을 메인 컴포넌트에 푸시" "Put text on path" = "텍스트를 패스에 배치" +"Question" = "질문" "R" = "R" "Radial" = "방사형" "Radius" = "반경" @@ -1025,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" = "폴더 이름 바꾸기" @@ -1080,6 +1100,7 @@ status = "draft — needs native review" "Rotation jitter" = "회전 지터" "Round" = "둥글게" "Rounded" = "둥근" +"Rounded rectangle" = "둥근 사각형" "Row" = "행" "Row gap" = "행 간격" "Row sizes" = "행 크기" @@ -1149,6 +1170,7 @@ status = "draft — needs native review" "Shape" = "도형" "Shape builder" = "도형 빌더" "Shape tools" = "도형 도구" +"Shapes" = "도형" "Share" = "공유" "Share this file" = "이 파일 공유" "Share: live session, export" = "공유: 라이브 세션, 내보내기" @@ -1206,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" = "별" @@ -1218,17 +1243,22 @@ status = "draft — needs native review" "Start ascending from / stop descending at" = "오름차순 시작 값 / 내림차순 끝 값" "Start from a blank canvas" = "빈 캔버스로 시작" "Start on path" = "패스 시작 위치" +"Start on the Home screen" = "홈에서 시작" "Start point" = "시작점" "Start sharing" = "공유 시작" "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" = "문자열" @@ -1272,6 +1302,7 @@ status = "draft — needs native review" "Text" = "텍스트" "Text align" = "텍스트 정렬" "Text content" = "텍스트 내용" +"Text size" = "텍스트 크기" "Text style" = "텍스트 스타일" "Text styles" = "텍스트 스타일" "Texts" = "텍스트" @@ -1302,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" = "타일 유형" @@ -1318,6 +1352,7 @@ status = "draft — needs native review" "Top left radius" = "왼쪽 위 반경" "Top right radius" = "오른쪽 위 반경" "Transform" = "변형" +"Triangle" = "삼각형" "Triangle arrow" = "삼각 화살표" "Trigger" = "트리거" "True" = "참" @@ -1340,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" = "위" @@ -1403,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" = "흰색" @@ -1574,6 +1611,7 @@ status = "draft — needs native review" "{name} is forgotten on this computer; the file stays on {server}" = "{name}의 기록을 이 컴퓨터에서 지움. 파일은 {server}에 남음" "{name} is on the server." = "{name}이(가) 서버에 있음." "{name} is spotlighting" = "{name} 님이 스포트라이트 중" +"{name} is too large to open in the browser (over 512 MB)." = "{name}은(는) 너무 커서 브라우저에서 열 수 없습니다(512MB 초과)." "{name} was removed from the server" = "{name}을(를) 서버에서 제거함" "{name} · deleted on main" = "{name} · 메인에서 삭제됨" "{name} · deleted on the branch" = "{name} · 브랜치에서 삭제됨" @@ -1664,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" = "선택 항목 교차" @@ -1786,7 +1833,9 @@ status = "draft — needs native review" "file.exportLayers" = "레이어 내보내기" "file.exportSvg" = "선택 항목을 SVG로 내보내기" "file.import" = "가져오기" +"file.moveTab" = "탭 이동" "file.new" = "새 디자인 파일" +"file.newBoard" = "새 보드" "file.open" = "열기" "file.openRecent" = "최근 파일 열기" "file.placeImage" = "이미지 배치" @@ -1923,6 +1972,7 @@ status = "draft — needs native review" "pref.reduceMotion" = "동작 줄이기" "pref.renameDuplicates" = "복제한 레이어 이름 바꾸기" "pref.showDimensions" = "개체에 치수 표시" +"pref.startOnHome" = "홈에서 시작" "pref.userName" = "댓글에 표시할 이름" "proto.action.add" = "프로토타입 액션 추가" "proto.action.move" = "프로토타입 액션 순서 변경" @@ -2049,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" = "연필" @@ -2064,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" = "위치로 이동" @@ -2128,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 8d8cac68..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" @@ -321,6 +324,7 @@ status = "draft — needs native review" "Corner smoothing" = "Hoekafvlakking" "Couldn't download the variables" = "De variabelen konden niet worden gedownload" "Couldn't reach {server}: showing what this computer remembers." = "Kon {server} niet bereiken: toont wat deze computer onthoudt." +"Couldn't read {name}." = "Kon {name} niet lezen." "Couldn't save automatically: {error}" = "Kan niet automatisch opslaan: {error}" "Count" = "Aantal" "Create" = "Maken" @@ -379,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" @@ -406,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" @@ -441,6 +447,7 @@ status = "draft — needs native review" "Effect settings" = "Effectinstellingen" "Effect styles" = "Effectstijlen" "Effects" = "Effecten" +"Elbow" = "Haaks" "Ellipse" = "Ellips" "Ellipses" = "Ellipsen" "Else" = "Anders" @@ -577,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" @@ -589,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" @@ -755,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)" @@ -814,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" @@ -917,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" @@ -952,6 +964,7 @@ status = "draft — needs native review" "PDF print settings" = "PDF-afdrukinstellingen" "Padding" = "Padding" "Page" = "Pagina" +"Page deleted" = "Pagina verwijderd" "Pages" = "Pagina's" "Pages added to the document" = "Pagina's toegevoegd aan het document" "Pages: {pages}" = "Pagina's: {pages}" @@ -985,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" @@ -999,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" @@ -1046,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" @@ -1146,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" @@ -1221,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" @@ -1287,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" @@ -1300,18 +1321,23 @@ status = "draft — needs native review" "Start from a blank canvas" = "Begin vanaf een leeg canvas" "Start from a template" = "Begin met een sjabloon" "Start on path" = "Begin op het pad" +"Start on the Home screen" = "Starten op Home" "Start point" = "Startpunt" "Start sharing" = "Delen starten" "Start sharing with the Share button, then pass on the code it gives" = "Begin met delen via de knop Delen en geef daarna de code door die je krijgt" "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" @@ -1356,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" @@ -1390,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" @@ -1406,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" @@ -1431,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" @@ -1673,6 +1702,7 @@ status = "draft — needs native review" "{name} is forgotten on this computer; the file stays on {server}" = "{name} is op deze computer vergeten; het bestand blijft op {server}" "{name} is on the server." = "{name} staat op de server." "{name} is spotlighting" = "{name} staat in de spotlight" +"{name} is too large to open in the browser (over 512 MB)." = "{name} is te groot om in de browser te openen (meer dan 512 MB)." "{name} was removed from the server" = "{name} is van de server verwijderd" "{name} · deleted on main" = "{name} · verwijderd op main" "{name} · deleted on the branch" = "{name} · verwijderd op de branch" @@ -1766,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" @@ -1890,7 +1929,9 @@ status = "draft — needs native review" "file.exportLayers" = "Lagen exporteren" "file.exportSvg" = "Selectie exporteren als SVG" "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" @@ -2032,6 +2073,7 @@ status = "draft — needs native review" "pref.reduceMotion" = "Beweging verminderen" "pref.renameDuplicates" = "Gedupliceerde lagen hernoemen" "pref.showDimensions" = "Afmetingen op objecten tonen" +"pref.startOnHome" = "Starten op Home" "pref.userName" = "Je naam in reacties" "proto.action.add" = "Prototypeactie toevoegen" "proto.action.move" = "Volgorde van prototypeactie wijzigen" @@ -2160,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" @@ -2175,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 new file mode 100644 index 00000000..7f301a6e --- /dev/null +++ b/crates/ui-egui/locales/pl.toml @@ -0,0 +1,2386 @@ +# FigCraft interface strings: Polish. +# +# Draft: machine-drafted, waiting for a native speaker's review. + +[meta] +name = "Polski" +status = "draft — needs native review" + +[ui] +"% of all commits" = "% wszystkich commitów" +"(no longer published)" = "(już niepublikowane)" +"({n} warnings: some entries were skipped)" = { one = "({n} ostrzeżenie: niektóre wpisy zostały pominięte)", few = "({n} ostrzeżenia: niektóre wpisy zostały pominięte)", many = "({n} ostrzeżeń: niektóre wpisy zostały pominięte)", other = "({n} ostrzeżeń: niektóre wpisy zostały pominięte)" } +"+ Create variable" = "+ Utwórz zmienną" +".fig (experimental)" = ".fig (eksperymentalny)" +"1…9, 0" = "1…9, 0" +"@{login}: {prs} PRs, {commits} commits, +{added} / −{deleted} lines (Δ {delta}), +{bin_added} / −{bin_deleted} binary assets, {first} – {last}" = "@{login}: PR: {prs}, commitów: {commits}, +{added} / −{deleted} wierszy (Δ {delta}), +{bin_added} / −{bin_deleted} zasobów binarnych, {first} – {last}" +"A collection of this file with these modes and variables: override their values per mode and add variables of its own" = "Kolekcja tego pliku z tymi trybami i zmiennymi: nadpisuj ich wartości dla poszczególnych trybów i dodawaj własne zmienne" +"A .figcraft file with this name already exists. Choose its full name to confirm replacement." = "Plik .figcraft o tej nazwie już istnieje. Wybierz pełną nazwę, aby potwierdzić zastąpienie." +"A layer on its own aligns to its frame: select more layers, or one in a frame" = "Pojedyncza warstwa wyrównuje się do swojej ramki: zaznacz więcej warstw lub warstwę w ramce" +"A responsive product page: hero, features, figures, a quote and a footer" = "Responsywna strona produktu: sekcja hero, funkcje, statystyki, cytat i stopka" +"A spring plays until it settles: its mass, stiffness and damping set how long" = "Animacja sprężynowa trwa do ustabilizowania: jej masa, sztywność i tłumienie określają czas" +"A title, an agenda, a big figure, a chart and a closing slide" = "Tytuł, agenda, wyróżniona liczba, wykres i slajd końcowy" +"A to Z" = "Od A do Z" +"About" = "O programie" +"About FigCraft" = "O programie FigCraft" +"About this server" = "O tym serwerze" +"Action" = "Akcja" +"Actions" = "Akcje" +"Actions for {name}" = "Akcje dla: {name}" +"Actions…" = "Akcje…" +"Active" = "Aktywny" +"Actual size (100%)" = "Rozmiar rzeczywisty (100%)" +"Add a category" = "Dodaj kategorię" +"Add a comment" = "Dodaj komentarz" +"Add a description" = "Dodaj opis" +"Add a frame (F) or a shape (R) to get started" = "Dodaj ramkę (F) lub kształt (R), aby rozpocząć" +"Add a layer or component to tile first" = "Najpierw dodaj warstwę lub komponent do powielenia" +"Add a note for developers" = "Dodaj notatkę dla programistów" +"Add action" = "Dodaj akcję" +"Add actions for when no condition holds" = "Dodaj akcje wykonywane, gdy żaden warunek nie jest spełniony" +"Add an annotation for developers" = "Dodaj adnotację dla programistów" +"Add another condition" = "Dodaj kolejny warunek" +"Add auto layout" = "Dodaj Auto layout" +"Add background behind overlay" = "Dodaj tło za nakładką" +"Add content to this slot" = "Dodaj zawartość do tego gniazda" +"Add dev resource" = "Dodaj zasób programistyczny" +"Add effect" = "Dodaj efekt" +"Add export setting" = "Dodaj ustawienie eksportu" +"Add fill" = "Dodaj wypełnienie" +"Add interaction" = "Dodaj interakcję" +"Add layout grid" = "Dodaj siatkę układu" +"Add library" = "Dodaj bibliotekę" +"Add library file…" = "Dodaj plik biblioteki…" +"Add max height" = "Dodaj maksymalną wysokość" +"Add max width" = "Dodaj maksymalną szerokość" +"Add min height" = "Dodaj minimalną wysokość" +"Add min width" = "Dodaj minimalną szerokość" +"Add mode" = "Dodaj tryb" +"Add new page" = "Dodaj nową stronę" +"Add or update collections from a variables JSON" = "Dodaj lub zaktualizuj kolekcje z pliku JSON ze zmiennymi" +"Add properties to make instances configurable" = "Dodaj właściwości, aby umożliwić konfigurację instancji" +"Add reaction" = "Dodaj reakcję" +"Add starting point" = "Dodaj punkt startowy" +"Add stroke" = "Dodaj obrys" +"Add variant" = "Dodaj wariant" +"Add variant with a new value" = "Dodaj wariant z nową wartością" +"Added" = "Dodano" +"Added the library \"{name}\"" = "Dodano bibliotekę „{name}”" +"Adding to {name}" = "Dodawanie do: {name}" +"Adjust" = "Dopasowanie" +"Adjustment presets" = "Presety korekt" +"Advanced" = "Zaawansowane" +"Advanced stroke" = "Zaawansowany obrys" +"After delay" = "Po opóźnieniu" +"After delay {ms}ms" = "Po opóźnieniu {ms} ms" +"Align" = "Wyrównaj" +"Align bottom" = "Wyrównaj do dołu" +"Align center" = "Wyrównaj do środka" +"Align horizontal centers" = "Wyrównaj poziomo do środka" +"Align left" = "Wyrównaj do lewej" +"Align middle" = "Wyrównaj do środka pionowo" +"Align right" = "Wyrównaj do prawej" +"Align text baselines" = "Wyrównaj do linii bazowych tekstu" +"Align top" = "Wyrównaj do góry" +"Align vertical centers" = "Wyrównaj pionowo do środka" +"Alignment" = "Wyrównanie" +"All" = "Wszystko" +"All categories" = "Wszystkie kategorie" +"All comments" = "Wszystkie komentarze" +"All components" = "Wszystkie komponenty" +"All fonts" = "Wszystkie czcionki" +"All supported properties" = "Wszystkie obsługiwane właściwości" +"All the fonts this file uses are installed" = "Wszystkie czcionki używane w tym pliku są zainstalowane" +"All variables" = "Wszystkie zmienne" +"Alpha" = "Alfa" +"Alphabetical" = "Alfabetycznie" +"Alt+drag" = "Alt+przeciągnięcie" +"An analytics screen on a grid: figures, charts drawn from vectors and a table" = "Ekran analityczny na siatce: wykresy wektorowe, liczby i tabela" +"An invite link that opens FigCraft and joins" = "Link z zaproszeniem otwierający FigCraft i dołączający do sesji" +"Anchor" = "Punkt zaczepienia" +"Android" = "Android" +"Angular" = "Kątowy" +"Animation" = "Animacja" +"Annotations" = "Adnotacje" +"Annotations shown on the canvas" = "Adnotacje widoczne na płótnie" +"Another server, or one that needs its key. The usual server is set in Preferences." = "Inny serwer lub serwer wymagający klucza. Domyślny serwer konfiguruje się w Preferencjach." +"Any status" = "Dowolny status" +"Anyone on your network with this code can edit the file, while this app stays open." = "Każdy w Twojej sieci z tym kodem może edytować plik, dopóki ta aplikacja pozostaje otwarta." +"Anyone with the edit link can change the file; the view link only lets people look. The links carry their secrets: share them like passwords. `figcraft-server links` makes more and revokes them." = "Każdy z linkiem do edycji może modyfikować plik; link do podglądu pozwala tylko oglądać. Linki zawierają tokeny: traktuj je jak hasła. Polecenie `figcraft-server links` tworzy i unieważnia linki." +"Anyone with this code can edit the file." = "Każdy z tym kodem może edytować plik." +"Anyone with this code can look at the file, not change it." = "Każdy z tym kodem może przeglądać plik bez możliwości zmian." +"Appearance" = "Wygląd" +"Apply a component property" = "Zastosuj właściwość komponentu" +"Apply variable" = "Zastosuj zmienną" +"Apply variable to text" = "Zastosuj zmienną do tekstu" +"Apply variable to visibility" = "Zastosuj zmienną do widoczności" +"Apps" = "Aplikacje" +"Apr" = "Kwi" +"Arc" = "Łuk" +"Arc ratio" = "Proporcja łuku" +"Arc start" = "Początek łuku" +"Arc sweep" = "Rozpiętość łuku" +"Archive" = "Zarchiwizuj" +"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" +"Aug" = "Sie" +"Auto" = "Automatycznie" +"Auto (space around)" = "Automatycznie (odstęp dookoła)" +"Auto (space between)" = "Automatycznie (odstęp między)" +"Auto (space evenly)" = "Automatycznie (równy odstęp)" +"Auto ({mode})" = "Automatycznie ({mode})" +"Auto columns" = "Automatyczne kolumny" +"Auto gap" = "Automatyczny odstęp" +"Auto height" = "Automatyczna wysokość" +"Auto layout" = "Automatyczny układ" +"Auto rows" = "Automatyczne wiersze" +"Auto width" = "Automatyczna szerokość" +"Back to the FigCraft server" = "Powrót do serwera FigCraft" +"Back to the font's value" = "Powrót do wartości czcionki" +"Back" = "Tył" +"Back to Ready for dev" = "Powrót do Gotowe do wdrożenia" +"Back to Ready for dev: the next change compares with how it is now" = "Powrót do Gotowe do wdrożenia: kolejna zmiana zostanie porównana ze stanem obecnym" +"Back to assets" = "Powrót do zasobów" +"Back to current" = "Powrót do bieżącej" +"Back to styles" = "Powrót do stylów" +"Back to the canvas from a focused region" = "Powrót do płótna z wybranego obszaru" +"Back to the current version" = "Powrót do bieżącej wersji" +"Back to the values the font style implies" = "Powrót do wartości wynikających ze stylu czcionki" +"Background" = "Tło" +"Background blur" = "Rozmycie tła" +"Basics" = "Podstawy" +"Before" = "Przed" +"Bend" = "Wygnij" +"Bevel" = "Ścięte" +"Binary assets added" = "Dodane pliki binarne" +"Binary assets removed" = "Usunięte pliki binarne" +"Black & white" = "Czerń i biel" +"Bleed" = "Spad" +"Bleed marks" = "Znaczniki spadu" +"Bleed marks need a bleed" = "Znaczniki spadu wymagają zdefiniowania spadu" +"Blend" = "Mieszanie" +"Blend mode" = "Tryb mieszania" +"Blue" = "Niebieski" +"Blur" = "Rozmycie" +"Boolean" = "Wartość logiczna" +"Boolean groups" = "Grupy logiczne (Boolean)" +"Both directions" = "W obu kierunkach" +"Bottom" = "Dół" +"Bottom left radius" = "Promień lewego dolnego narożnika" +"Bottom right radius" = "Promień prawego dolnego narożnika" +"Branch" = "Gałąź" +"Branch archived" = "Gałąź zarchiwizowana" +"Branch merged" = "Gałąź scalona" +"Branch name" = "Nazwa gałęzi" +"Branch of {file}" = "Gałąź pliku {file}" +"Branch of {file} · merged or archived" = "Gałąź pliku {file} · scalona lub zarchiwizowana" +"Branch updated from main" = "Gałąź zaktualizowana z głównej" +"Branch {name}" = "Gałąź {name}" +"Branches" = "Gałęzie" +"Bound to {name}. Click to detach the variable." = "Powiązano z: {name}. Kliknij, aby odłączyć zmienną." +"Bring forward" = "Przenieś do przodu" +"Bring the main file's changes into the branch" = "Pobierz zmiany z pliku głównego do gałęzi" +"Bring to front" = "Przenieś na wierzch" +"Bristle" = "Włosiankowy" +"Browse" = "Przeglądaj" +"Brush" = "Pędzel" +"Brush rotation jitter" = "Fluktuacja obrotu pędzla" +"Brush scatter" = "Rozrzut pędzla" +"Brush size jitter" = "Fluktuacja rozmiaru pędzla" +"Brush spacing" = "Odstęp pędzla" +"Bulleted list" = "Lista punktowana" +"C or Shift+C" = "C lub Shift+C" +"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…" +"Can edit" = "Może edytować" +"Can't read this library: {e}" = "Nie można odczytać tej biblioteki: {e}" +"Cancel" = "Anuluj" +"Canvas" = "Płótno" +"Canvas stacking" = "Kolejność na płótnie" +"Cap height to baseline" = "Wysokość wersalików do linii bazowej" +"Case" = "Wielkość liter" +"Case-sensitive forms" = "Formy zależne od wielkości liter" +"Categories" = "Kategorie" +"Center" = "Środek" +"Change alias" = "Zmień alias" +"Change resolved" = "Zmiana rozwiązana" +"Change style" = "Zmień styl" +"Change to" = "Zmień na" +"Change to needs a layer inside a variant" = "Opcja Zmień na wymaga warstwy wewnątrz wariantu" +"Changed" = "Zmieniono" +"Changed on both sides: keep one" = "Zmieniono po obu stronach: zachowaj jedną wersję" +"Changes on main" = "Zmiany w gałęzi głównej" +"Changes on the branch" = "Zmiany w gałęzi" +"Changes unknown" = "Nieznane zmiany" +"Choose React, Leptos, SwiftUI or Compose above to connect this component to its code." = "Wybierz React, Leptos, SwiftUI lub Compose powyżej, aby połączyć ten komponent z jego kodem." +"Choose another profile to change it." = "Wybierz inny profil, aby go zmienić." +"Choose collection" = "Wybierz kolekcję" +"Choose image" = "Wybierz obraz" +"Choose mode" = "Wybierz tryb" +"Choose variable" = "Wybierz zmienną" +"Circle" = "Koło" +"Clear recent" = "Wyczyść ostatnie" +"Click + to replace mixed content" = "Kliknij +, aby zastąpić zawartość mieszaną" +"Click anywhere on the canvas to leave one." = "Kliknij w dowolnym miejscu na płótnie, aby zostawić komentarz." +"Click or drag to merge · {alt} removes" = "Kliknij lub przeciągnij, aby scalić · {alt} usuwa" +"Click or drag to remove · {alt} merges" = "Kliknij lub przeciągnij, aby usunąć · {alt} scala" +"Click to record a key" = "Kliknij, aby zarejestrować klawisz" +"Clip content" = "Przycinaj zawartość" +"Clip to shape" = "Przytnij do kształtu" +"Close" = "Zamknij" +"Close overlay" = "Zamknij nakładkę" +"Close preview" = "Zamknij podgląd" +"Close tab" = "Zamknij kartę" +"Close version history" = "Zamknij historię wersji" +"Close when clicking outside" = "Zamknij przy kliknięciu poza obszarem" +"Code" = "Kod" +"Code syntax" = "Składnia kodu" +"Collapse" = "Zwiń" +"Collapse panel" = "Zwiń panel" +"Collection" = "Kolekcja" +"Collections" = "Kolekcje" +"Color" = "Kolor" +"Color burn" = "Ściemnianie" +"Color dodge" = "Rozjaśnianie" +"Color format" = "Format koloru" +"Color profile" = "Profil kolorów" +"Color styles" = "Style kolorów" +"Color values in this file are in this color space." = "Wartości kolorów w tym pliku są w tej przestrzeni kolorów." +"Color variables" = "Zmienne koloru" +"Colour" = "Kolor" +"Column" = "Kolumna" +"Column gap" = "Odstęp kolumn" +"Column sizes" = "Rozmiary kolumn" +"Column span" = "Rozpiętość kolumn" +"Columns" = "Kolumny" +"Columns {count}" = "Kolumny: {count}" +"Comment" = "Komentarz" +"Comments" = "Komentarze" +"Commits" = "Commity" +"Company" = "Firma" +"Compare changes" = "Porównaj zmiany" +"Compatibility notes" = "Uwagi dotyczące zgodności" +"Completed" = "Ukończone" +"Component" = "Komponent" +"Component name" = "Nazwa komponentu" +"Component property" = "Właściwość komponentu" +"Component set" = "Zestaw komponentów" +"Component sets" = "Zestawy komponentów" +"Components" = "Komponenty" +"Conditional" = "Warunek (Conditional)" +"Confetti" = "Konfetti" +"Conflicts" = "Konflikty" +"Connect to code" = "Połącz z kodem" +"Connected" = "Połączono" +"Connected to code: {from}" = "Połączono z kodem: {from}" +"Connection lost, reconnecting (attempt {attempt})…" = "Utracono połączenie, ponowne łączenie (próba {attempt})…" +"Constrain proportions" = "Zachowaj proporcje" +"Constraints" = "Więzy" +"Content" = "Zawartość" +"Contents hidden" = "Zawartość ukryta" +"Contents only" = "Tylko zawartość" +"Contextual alternates" = "Alternatywy kontekstowe" +"Contrast" = "Kontrast" +"Contributors" = "Współtwórcy" +"Contributors: {people} · Commits: {commits}" = "Współtwórcy: {people} · Commity: {commits}" +"Controller" = "Kontroler" +"Cool" = "Chłodny" +"Copied as CSS" = "Skopiowano jako CSS" +"Copied as PNG" = "Skopiowano jako PNG" +"Copied as SVG" = "Skopiowano jako SVG" +"Copied as text" = "Skopiowano jako tekst" +"Copied {value}" = "Skopiowano {value}" +"Copy" = "Kopiuj" +"Copy as" = "Kopiuj jako" +"Copy as CSS" = "Kopiuj jako CSS" +"Copy as PNG" = "Kopiuj jako PNG" +"Copy as SVG" = "Kopiuj jako SVG" +"Copy code" = "Kopiuj kod" +"Copy link" = "Kopiuj link" +"Copy the {label}" = "Kopiuj {label}" +"Copy {value}" = "Kopiuj {value}" +"Corner radius" = "Zaokrąglenie rogów" +"Corner smoothing" = "Wygładzanie narożników (Corner smoothing)" +"Couldn't download the variables" = "Nie udało się pobrać zmiennych" +"Couldn't reach {server}: showing what this computer remembers." = "Nie udało się połączyć z {server}: pokazywanie zawartości zapamiętanej przez ten komputer." +"Couldn't read {name}." = "Nie udało się odczytać pliku {name}." +"Couldn't save automatically: {error}" = "Nie udało się zapisać automatycznie: {error}" +"Count" = "Licznik" +"Create" = "Utwórz" +"Create a variable collection first" = "Najpierw utwórz kolekcję zmiennych" +"Create a variable first" = "Najpierw utwórz zmienną" +"Create alias" = "Utwórz alias" +"Create branch" = "Utwórz gałąź" +"Create collection" = "Utwórz kolekcję" +"Create component" = "Utwórz komponent" +"Create component property" = "Utwórz właściwość komponentu" +"Create components, styles or variables to publish them as a library." = "Twórz komponenty, style lub zmienne, aby opublikować je jako bibliotekę." +"Create property…" = "Utwórz właściwość…" +"Create style" = "Utwórz styl" +"Create style from the selected text" = "Utwórz styl z zaznaczonego tekstu" +"Created" = "Utworzono" +"Crop" = "Przytnij" +"Crop marks" = "Znaczniki przycięcia" +"Ctrl+arrows" = "Ctrl+strzałki" +"Ctrl+click" = "Ctrl+kliknięcie" +"Ctrl+scroll" = "Ctrl+przewijanie" +"Current name" = "Bieżąca nazwa" +"Current version" = "Bieżąca wersja" +"Cursor chat" = "Czat kursora" +"Custom" = "Niestandardowy" +"Custom size" = "Rozmiar niestandardowy" +"Cut" = "Wytnij" +"Dark" = "Ciemny" +"Darken" = "Ciemniej" +"Dash" = "Kreska" +"Dashboard" = "Pulpit" +"Date created" = "Data utworzenia" +"Dec" = "Gru" +"Decoration style" = "Styl dekoracji" +"Decoration" = "Dekoracja tekstu" +"Deep select" = "Głębokie zaznaczanie" +"Default" = "Domyślne" +"Default content" = "Domyślna zawartość" +"Default value (the top-left variant)" = "Wartość domyślna (lewy górny wariant)" +"Delay" = "Opóźnienie" +"Delete" = "Usuń" +"Delete category (its annotations stay)" = "Usuń kategorię (jej adnotacje pozostaną)" +"Delete collection" = "Usuń kolekcję" +"Delete group" = "Usuń grupę" +"Delete mode" = "Usuń tryb" +"Delete page" = "Usuń stronę" +"Delete preset" = "Usuń ustawienie" +"Delete property" = "Usuń właściwość" +"Delete the style (layers keep their values)" = "Usuń styl (warstwy zachowają swoje wartości)" +"Delete thread" = "Usuń wątek" +"Delete variable" = "Usuń zmienną" +"Delete “{name}”? This can't be undone." = "Usunąć „{name}”? Tej operacji nie można cofnąć." +"Density" = "Gęstość" +"Depth" = "Głębia" +"Describe how and when to use this component" = "Opisz sposób i warunki użycia tego komponentu" +"Describe what changed" = "Opisz wprowadzone zmiany" +"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" +"Detach alias" = "Odłącz alias" +"Detach instance" = "Odłącz instancję" +"Detach property" = "Odłącz właściwość" +"Detach style" = "Odłącz styl" +"Detach styles" = "Odłącz style" +"Detach variable" = "Odłącz zmienną" +"Details" = "Szczegóły" +"Dev Mode" = "Tryb deweloperski" +"Dev resources" = "Zasoby programistyczne" +"Development" = "Programowanie" +"Device" = "Urządzenie" +"Device at 100%" = "Urządzenie w 100%" +"Diamond" = "Rombowy" +"Difference" = "Różnica" +"Dimensions" = "Wymiary" +"Direction" = "Kierunek" +"Disconnect from code" = "Odłącz od kodu" +"Dispersion" = "Dyspersja" +"Display name" = "Nazwa wyświetlana" +"Dissolve" = "Rozpuszczanie" +"Distribute and tidy" = "Rozmieść i uporządkuj" +"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ę" +"Dots" = "Kropki" +"Dotted" = "Kropkowany" +"Down" = "W dół" +"Drafts" = "Szkice" +"Drag the stroke to add a width point" = "Przeciągnij obrys, aby dodać punkt szerokości" +"Draw" = "Rysuj" +"Draw a frame" = "Narysuj ramkę" +"Drawing tools" = "Narzędzia rysowania" +"Drop a .figcraft, .fig, .json, .svg or image file." = "Upuść plik .figcraft, .fig, .json, .svg lub plik obrazu." +"Drop to import" = "Upuść, aby zaimportować" +"Drop to open" = "Upuść, aby otworzyć" +"Drop shadow" = "Cień" +"Dual-licensed MIT OR Apache-2.0. Made by the ArtCraft team and community." = "Podwójna licencja MIT LUB Apache-2.0. Stworzone przez zespół ArtCraft i społeczność." +"Duo" = "Duet" +"Duplicate" = "Powiel" +"Duplicate by dragging" = "Powiel przez przeciągnięcie" +"Duplicate mode" = "Powiel tryb" +"Duplicate page" = "Powiel stronę" +"Edit" = "Edycja" +"Edit as an expression" = "Edytuj jako wyrażenie" +"Edit colour" = "Edytuj kolor" +"Edit comment" = "Edytuj komentarz" +"Edit dev resource" = "Edytuj zasób programistyczny" +"Edit link" = "Edytuj link" +"Edit link copied" = "Skopiowano link do edycji" +"Edit style" = "Edytuj styl" +"Edit variable" = "Edytuj zmienną" +"Edit with the builder" = "Edytuj za pomocą kreatora" +"Edited {when}" = "Edytowano {when}" +"Effect settings" = "Ustawienia efektów" +"Effect styles" = "Style efektów" +"Effects" = "Efekty" +"Elbow" = "Łamany" +"Ellipse" = "Elipsa" +"Ellipses" = "Elipsy" +"Else" = "W przeciwnym razie" +"Else if" = "W przeciwnym razie jeśli" +"Embedded profile" = "Wbudowany profil" +"Empty" = "Puste" +"End" = "End" +"End point" = "Punkt końcowy" +"Ends the session; everyone keeps their copy" = "Kończy sesję; każdy zachowuje swoją kopię" +"Enter" = "Enter" +"Enter the address of a figcraft-server and press Browse to see the plugins it offers." = "Wprowadź adres figcraft-server i kliknij Przeglądaj, aby zobaczyć dostępne wtyczki." +"Enter to save · Esc to cancel" = "Enter, aby zapisać · Esc, aby anulować" +"Eraser" = "Gumka" +"Esc" = "Esc" +"Essential" = "Podstawowe" +"Every conflict has a choice." = "Każdy konflikt ma rozwiązanie do wyboru." +"Every top-level frame already starts a flow" = "Każda ramka najwyższego poziomu rozpoczyna już przepływ" +"Exact opacity (4 then 5: 45%)" = "Dokładna nieprzezroczystość (4, potem 5: 45%)" +"Exclude" = "Wyklucz" +"Excluded" = "Wykluczono" +"Excluded from layout" = "Wykluczono z układu" +"Excludes" = "Wyklucza" +"Exclusion" = "Wykluczenie" +"Exit focus view (Esc)" = "Wyjdź z widoku skupienia (Esc)" +"Expand" = "Rozwiń" +"Export" = "Eksportuj" +"Export all" = "Eksportuj wszystko" +"Export frames and layers as PNG, JPG, SVG or PDF" = "Eksportuj ramki i warstwy jako PNG, JPG, SVG lub PDF" +"Export page as PNG" = "Eksportuj stronę jako PNG" +"Export settings" = "Ustawienia eksportu" +"Export variables" = "Eksportuj zmienne" +"Export {name}" = "Eksportuj {name}" +"Export {n} layers" = { one = "Eksportuj {n} warstwę", few = "Eksportuj {n} warstwy", many = "Eksportuj {n} warstw", other = "Eksportuj {n} warstw" } +"Exported {file}" = "Wyeksportowano {file}" +"Exported {name}" = "Wyeksportowano {name}" +"Exported {name}; {n} kinds of content were left out" = { one = "Wyeksportowano {name}; pominięto {n} rodzaj zawartości", few = "Wyeksportowano {name}; pominięto {n} rodzaje zawartości", many = "Wyeksportowano {name}; pominięto {n} rodzajów zawartości", other = "Wyeksportowano {name}; pominięto {n} rodzajów zawartości" } +"Exported {n} files" = { one = "Wyeksportowano {n} plik", few = "Wyeksportowano {n} pliki", many = "Wyeksportowano {n} plików", other = "Wyeksportowano {n} plików" } +"Export…" = "Eksport…" +"Expose properties to instances" = "Udostępnij właściwości instancjom" +"Exposure" = "Czas naświetlania" +"Expression" = "Wyrażenie" +"Extend collection" = "Rozszerz kolekcję" +"Extends \"{name}\": overridden values are marked" = "Rozszerza „{name}”: nadpisane wartości są oznaczone" +"Eye" = "Oko" +"F" = "F" +"Fade" = "Zanikanie" +"False" = "Fałsz" +"Feb" = "Lut" +"Fetching from the server…" = "Pobieranie z serwera…" +"FigCraft couldn't open its window: {error}" = "FigCraft nie mógł otworzyć okna: {error}" +"FigCraft doesn't support every feature of these files yet. The notes list what was left out or changed in the imported copy; the original file is unchanged." = "FigCraft nie obsługuje jeszcze wszystkich funkcji tych plików. Uwagi wymieniają elementy pominięte lub zmienione w zaimportowanej kopii; oryginalny plik pozostaje niezmieniony." +"File" = "Plik" +"File name" = "Nazwa pliku" +"File not found" = "Nie znaleziono pliku" +"File published" = "Plik został opublikowany" +"Files aren't end-to-end encrypted: the server's operator can read them." = "Pliki nie są szyfrowane end-to-end: administrator serwera może je odczytać." +"Files nobody opens for {n} days are deleted." = { one = "Pliki, których nikt nie otwiera przez {n} dzień, są usuwane.", few = "Pliki, których nikt nie otwiera przez {n} dni, są usuwane.", many = "Pliki, których nikt nie otwiera przez {n} dni, są usuwane.", other = "Pliki, których nikt nie otwiera przez {n} dni, są usuwane." } +"Fill" = "Wypełnienie" +"Fill (fr)" = "Wypełnienie (fr)" +"Fill container" = "Wypełnij kontener" +"Fill window" = "Wypełnij okno" +"Fills" = "Wypełnienia" +"Fill|sizing" = "Fill|sizing" +"Filter and sort" = "Filtruj i sortuj" +"Find" = "Znajdź" +"Find and replace" = "Znajdź i zamień" +"Find layers" = "Znajdź warstwy" +"First" = "Pierwszy" +"First commit" = "Pierwszy commit" +"First on top" = "Pierwszy na wierzchu" +"Fit" = "Dopasuj" +"Fit device to window" = "Dopasuj urządzenie do okna" +"Fit to contents" = "Dopasuj do zawartości" +"Fit to window" = "Dopasuj do okna" +"Fit width" = "Dopasuj szerokość" +"Fixed" = "Stałe" +"Fixed gap" = "Stały odstęp" +"Fixed size" = "Stały rozmiar" +"Flatten" = "Spłaszcz" +"Flip" = "Odbij" +"Flip horizontal" = "Odbij w poziomie" +"Flip text to the other side" = "Przerzuć tekst na drugą stronę" +"Flip vertical" = "Odbij w pionie" +"Flip width profile" = "Odwróć profil szerokości" +"Flow starting point" = "Punkt startowy przepływu" +"Flow starting points" = "Punkty startowe przepływu" +"Flows" = "Przepływy" +"Focus view" = "Widok skupienia" +"Fold autosaves" = "Zwiń automatyczne zapisy" +"Folder: click to collapse" = "Folder: kliknij, aby zwinąć" +"Folder: click to expand" = "Folder: kliknij, aby rozwinąć" +"Follow" = "Obserwuj" +"Follow {name}" = "Obserwuj: {name}" +"Following" = "Obserwowanie" +"Following {name} · {key} to stop" = "Obserwowanie {name} · {key}, aby zatrzymać" +"Font" = "Czcionka" +"Font not installed" = "Czcionka niezainstalowana" +"Font size" = "Rozmiar czcionki" +"Font style" = "Styl czcionki" +"Forget on this computer" = "Zapomnij na tym komputerze" +"Four screens of a trail app with components and a prototype flow" = "Cztery ekrany aplikacji z komponentami i przepływem prototypu" +"Fractions" = "Ułamki" +"Frame" = "Ramka" +"Frame selection" = "Utwórz ramkę z zaznaczenia" +"Frame tools" = "Narzędzia ramek" +"Frames" = "Ramki" +"From the library \"{name}\": read-only" = "Z biblioteki „{name}”: tylko do odczytu" +"From {name}" = "Od {name}" +"From {server}" = "Z serwera {server}" +"Frost" = "Szron" +"Gap" = "Odstęp" +"Gap between columns" = "Odstęp między kolumnami" +"Gap between items" = "Odstęp między elementami" +"Gap between rows" = "Odstęp między wierszami" +"Getting started" = "Pierwsze kroki" +"Give the address of a server open to the public, or a server's address with its key (`figcraft-server key` prints it)." = "Podaj adres serwera publicznego lub adres serwera z jego kluczem (polecenie `figcraft-server key` go wyświetla)." +"Glass" = "Szkło" +"Go to main component" = "Przejdź do głównego komponentu" +"Go to {name}" = "Przejdź do: {name}" +"Grab bag" = "Lista dowolna" +"Gradient" = "Gradient" +"Gray" = "Szary" +"Green" = "Zielony" +"Greyscale components and two example screens to sketch a flow with" = "Komponenty w odcieniach szarości i dwa przykładowe ekrany do naszkicowania przepływu" +"Grid" = "Siatka" +"Grid ({size})" = "Siatka ({size})" +"Grid styles" = "Style siatki" +"Grid {size}" = "Siatka {size}" +"Group" = "Grupa" +"Group selection" = "Grupuj zaznaczenie" +"Groups" = "Grupy" +"Gutter" = "Odstęp" +"Hand tool" = "Narzędzie Rączka" +"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" +"Help page" = "Strona pomocy" +"Here: {names}" = "Tutaj: {names}" +"Hidden" = "Ukryty" +"Hide contents" = "Ukryj zawartość" +"Hide from publishing" = "Ukryj przed publikacją" +"Hide section contents" = "Ukryj zawartość sekcji" +"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" +"Hold Ctrl" = "Przytrzymaj Ctrl" +"Hold Z" = "Przytrzymaj Z" +"Hold ⌃ or ⌘" = "Przytrzymaj ⌃ lub ⌘" +"Hold ⌥" = "Przytrzymaj ⌥" +"Hold ⌥Z" = "Przytrzymaj ⌥Z" +"Home" = "Start" +"Horizontal" = "Poziomo" +"Horizontal align" = "Wyrównanie poziome" +"Horizontal hex" = "Poziomy sześciokąt" +"Horizontal padding" = "Marginesy poziome" +"Horizontal spacing" = "Odstępy poziome" +"Hosted by {operator}" = "Hostowane przez {operator}" +"Hover a layer to see its properties" = "Najedź na warstwę, aby zobaczyć jej właściwości" +"Hover other layers to measure the distance" = "Najedź na inne warstwy, aby zmierzyć odległość" +"Hue" = "Odcień" +"Hug" = "Dopasuj" +"Hug contents" = "Dopasuj do zawartości" +"Icon components on a 24 px grid with keylines and a size preview" = "Komponenty ikon na siatce 24 px z liniami pomocniczymi i podglądem rozmiaru" +"Icon set starter" = "Zestaw początkowy ikon" +"If it keeps happening, please report it at {link} with this message." = "Jeśli problem będzie się powtarzał, zgłoś go pod adresem {link}, dołączając tę wiadomość." +"If" = "Jeżeli" +"If {condition}" = "Jeżeli {condition}" +"Ignore auto layout (absolute position)" = "Ignoruj automatyczny układ (pozycja bezwzględna)" +"Image" = "Obraz" +"Images" = "Obrazy" +"Import" = "Importuj" +"Import path" = "Ścieżka importu" +"Import plugin from manifest" = "Zaimportuj wtyczkę z manifestu" +"Import plugin from manifest…" = "Zaimportuj wtyczkę z manifestu…" +"Import report" = "Raport importu" +"Import variables" = "Importuj zmienne" +"Imported document" = "Zaimportowany dokument" +"Imported {name}" = "Zaimportowano {name}" +"Imported {n} new variables, updated {updated}" = { one = "Zaimportowano {n} nową zmienną, zaktualizowano {updated}", few = "Zaimportowano {n} nowe zmienne, zaktualizowano {updated}", many = "Zaimportowano {n} nowych zmiennych, zaktualizowano {updated}", other = "Zaimportowano {n} nowych zmiennych, zaktualizowano {updated}" } +"Import…" = "Importuj…" +"In an instance, this comes from the main component" = "W instancji pochodzi to z głównego komponentu" +"In the browser you can join sessions the desktop app hosts and files on a FigCraft server. A page opened over https reaches other computers only through wss:// links (a server behind TLS)." = "W przeglądarce możesz dołączać do sesji hostowanych przez aplikację desktopową oraz do plików na serwerze FigCraft. Strona otwarta przez https łączy się z innymi komputerami tylko przez linki wss:// (serwer za TLS)." +"Include bounding box" = "Dołącz ramkę otaczającą" +"Include id attribute" = "Dołącz atrybut id" +"Included" = "Uwzględniono" +"Included in layout" = "Uwzględniono w układzie" +"Individual corners" = "Niezależne narożniki" +"Individual strokes" = "Niezależne obrysy" +"Ink" = "Farba" +"Inner shadow" = "Cień wewnętrzny" +"Inside" = "Wewnątrz" +"Inspect" = "Inspekcja" +"Inspect the layer under the pointer: size, colours, typography" = "Sprawdź warstwę pod kursorem: rozmiar, kolory, typografia" +"Install plugin" = "Zainstaluj wtyczkę" +"Install" = "Zainstaluj" +"Installed {name}" = "Zainstalowano {name}" +"Installed" = "Zainstalowano" +"Installing…" = "Instalowanie…" +"Instance" = "Instancja" +"Instance properties" = "Właściwości instancji" +"Instance swap" = "Podmiana instancji" +"Instance swap (no other component)" = "Podmiana instancji (brak innego komponentu)" +"Instances" = "Instancje" +"Instant" = "Natychmiastowo" +"Intensity" = "Siła" +"Interaction" = "Interakcja" +"Interactions" = "Interakcje" +"Interface design and prototyping. A free, open-source, clean-room design tool written from scratch in Rust, part of the ArtCraft Crafting Apps." = "Projektowanie interfejsów i prototypowanie. Darmowe, otwartoźródłowe, czysto implementowane narzędzie projektowe napisane od podstaw w Rust, część ArtCraft Crafting Apps." +"Intersect" = "Przetnij" +"Intersects" = "Przecina" +"Invalid color" = "Nieprawidłowy kolor" +"Invite link" = "Link z zaproszeniem" +"Invite link copied" = "Skopiowano link z zaproszeniem" +"It asks permission to:" = "Wymaga uprawnień do:" +"Jan" = "Sty" +"Join" = "Połącz" +"Join code copied" = "Skopiowano kod dołączenia" +"Join code or invite link" = "Kod dołączenia lub link z zaproszeniem" +"Join live session" = "Dołącz do sesji na żywo" +"Join us on Discord" = "Dołącz do nas na Discordzie" +"Join with a code" = "Dołącz za pomocą kodu" +"Joined" = "Dołączono" +"Joined {file}" = "Dołączono do: {file}" +"Joining…" = "Dołączanie…" +"Join|stroke" = "Join|stroke" +"Jul" = "Lip" +"Jun" = "Cze" +"Just now" = "Przed chwilą" +"Justify" = "Wyjustuj" +"Keep appearance" = "Zachowaj wygląd" +"Keep appearance converts every color value to {profile} so colors look the same. Keep values leaves the numbers as they are, so colors shift." = "Zachowanie wyglądu konwertuje każdą wartość koloru na {profile}, aby kolory wyglądały tak samo. Zachowanie wartości pozostawia liczby bez zmian, co może zmienić odcienie barw." +"Keep tool selected after use" = "Pozostaw narzędzie wybrane po użyciu" +"Keep values" = "Zachowaj wartości" +"Key/Gamepad" = "Klawisz/Kontroler" +"Key/Gamepad {key}" = "Klawisz/Kontroler {key}" +"Keyboard shortcuts" = "Skróty klawiaturowe" +"Kits" = "Zestawy" +"Landing page" = "Strona docelowa (Landing page)" +"Language" = "Język" +"Lasso" = "Lasso" +"Last" = "Ostatni" +"Last change: {note}" = "Ostatnia zmiana: {note}" +"Last commit" = "Ostatni commit" +"Last modified" = "Ostatnia modyfikacja" +"Last on top" = "Ostatni na wierzchu" +"Layer blur" = "Rozmycie warstwy" +"Layer name" = "Nazwa warstwy" +"Layers" = "Warstwy" +"Layers in an instance take their position from the main component" = "Warstwy w instancji przyjmują pozycję z głównego komponentu" +"Layout" = "Układ" +"Layout grid" = "Siatka układu" +"Layout grid settings" = "Ustawienia siatki układu" +"Layout grids" = "Siatki układu" +"Leave session" = "Opuść sesję" +"Leaves" = "Opuszcza" +"Left" = "Do lewej" +"Left & right" = "Lewa i prawa" +"Left side" = "Lewa strona" +"Left side width" = "Szerokość lewej strony" +"Letter case" = "Wielkość liter" +"Letter spacing" = "Światło tekstu" +"Libraries" = "Biblioteki" +"Libraries in this file" = "Biblioteki w tym pliku" +"Library updates available" = "Dostępne aktualizacje biblioteki" +"Library: click to collapse" = "Biblioteka: kliknij, aby zwinąć" +"Library: click to expand" = "Biblioteka: kliknij, aby rozwinąć" +"Library: its variables are read-only here; edit them in the library file and publish it" = "Biblioteka: jej zmienne są tutaj tylko do odczytu; edytuj je w pliku biblioteki i opublikuj" +"Light" = "Lekki" +"Lighten" = "Jaśniej" +"Light|glass" = "Light|glass" +"Line" = "Linia" +"Line arrow" = "Strzałka liniowa" +"Line delta" = "Różnica wierszy" +"Line height" = "Interlinia" +"Linear" = "Liniowy" +"Lines" = "Linie" +"Lines +/−" = "Wiersze +/−" +"Lines added" = "Dodane wiersze" +"Lines deleted" = "Usunięte wiersze" +"Link" = "Łącze" +"Link copied" = "Skopiowano link" +"Link docs, code or tickets for developers." = "Dołącz dokumentację, kod lub zgłoszenia dla programistów." +"Link frames on the Prototype tab, then present the flow" = "Połącz ramki na karcie Prototyp, a następnie zaprezentuj przepływ" +"Link this component to its code component: instances then show your real snippet with their values." = "Połącz ten komponent z jego odpowiednikiem w kodzie: instancje wyświetlą wtedy rzeczywisty fragment kodu z ich wartościami." +"List spacing" = "Odstępy listy" +"List style" = "Styl listy" +"Live session" = "Sesja na żywo" +"Live · {people}" = "Na żywo · {people}" +"Local components" = "Komponenty lokalne" +"Local network" = "Sieć lokalna" +"Local styles" = "Style lokalne" +"Local variables" = "Zmienne lokalne" +"Lock/Unlock" = "Zablokuj/Odblokuj" +"Locked" = "Zablokowane" +"Lowercase" = "Małe litery" +"Luminance" = "Luminancja" +"Luminosity" = "Jasność" +"Main" = "Główny" +"Main component deleted" = "Usunięto główny komponent" +"Main menu" = "Menu główne" +"Make a component" = "Utwórz komponent" +"Make slot" = "Utwórz gniazdo (slot)" +"Mar" = "Mar" +"Margin" = "Margines" +"Mark all as read" = "Oznacz wszystkie jako przeczytane" +"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)" +"Max H" = "Maks. wys." +"Max W" = "Maks. szer." +"Max lines" = "Maks. linii" +"Maximize" = "Maksymalizuj" +"May" = "maja" +"Measure to the selection" = "Zmierz odległość do zaznaczenia" +"Merge into main" = "Scal z gałęzią główną" +"Merge regions" = "Scal obszary" +"Merge the branch into the main file (one undo step) and archive it" = "Scal gałąź z plikiem głównym (jeden krok cofnięcia) i zarchiwizuj ją" +"Merged" = "Scalone" +"Merged PRs" = "Scalone PR" +"Min H" = "Min. wys." +"Min W" = "Min. szer." +"Minimize" = "Minimalizuj" +"Mirror angle" = "Kąt odbicia lustrzanego" +"Mirror angle and length" = "Kąt i długość odbicia lustrzanego" +"Mirroring" = "Odbicie lustrzane" +"Missing" = "Brak" +"Missing fonts" = "Brakujące czcionki" +"Missing layer" = "Brakująca warstwa" +"Missing style" = "Brakujący styl" +"Missing variable" = "Brakująca zmienna" +"Miter" = "Ostre" +"Miter angle" = "Kąt ścięcia (miter)" +"Mixed" = "Różne wartości" +"Mixed fonts" = "Różne czcionki" +"Mixed interactions: click + to replace them" = "Różne interakcje: kliknij +, aby je zastąpić" +"Mobile app" = "Aplikacja mobilna" +"Mode" = "Tryb" +"Model" = "Model" +"Models" = "Modele" +"Mono" = "Mono" +"More" = "Więcej" +"More instance actions" = "Więcej akcji instancji" +"Mouse down" = "Naciśnięcie myszy" +"Mouse enter" = "Wskazanie myszą (Mouse enter)" +"Mouse leave" = "Opuszczenie kursora myszy" +"Mouse up" = "Zwolnienie przycisku myszy" +"Move along the toolbar or the layer rows" = "Przechodź wzdłuż paska narzędzi lub wierszy warstw" +"Move" = "Przesuń" +"Move column left" = "Przesuń kolumnę w lewo" +"Move column right" = "Przesuń kolumnę w prawo" +"Move down" = "Przesuń w dół" +"Move in" = "Wsuń" +"Move out" = "Wysuń" +"Move tools" = "Narzędzia przesuwania" +"Move up" = "Przesuń w górę" +"Moved to {name}" = "Przeniesiono do: {name}" +"Multi" = "Wiele" +"Multiply" = "Mnożenie" +"Muted" = "Stonowany" +"Name" = "Nazwa" +"Name (A–Z)" = "Nazwa (A–Z)" +"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" +"Next frame" = "Następna klatka" +"Next ready for dev" = "Następny element gotowy do wdrożenia" +"Next size option" = "Następna opcja rozmiaru" +"No assets: no export settings, icons or images. Add export settings in the Design tab." = "Brak zasobów: brak ustawień eksportu, ikon lub obrazów. Dodaj ustawienia eksportu na karcie Projekt." +"No boolean variables fit here." = "Żadna zmienna logiczna (boolean) tutaj nie pasuje." +"No boolean variables yet. Create them in Local variables." = "Brak zmiennych logicznych. Utwórz je w Zmiennych lokalnych." +"No branches. A branch is a copy of the file to try changes in, then review and merge." = "Brak gałęzi. Gałąź to kopia pliku do testowania zmian, a następnie ich przeglądu i scalenia." +"No category" = "Bez kategorii" +"No changes" = "Brak zmian" +"No color styles" = "Brak stylów kolorów" +"No colour variables fit here." = "Żadna zmienna koloru tutaj nie pasuje." +"No colour variables yet. Create them in Local variables." = "Brak zmiennych koloru. Utwórz je w Zmiennych lokalnych." +"No comments yet" = "Brak komentarzy" +"No compatibility warnings reported" = "Nie zgłoszono ostrzeżeń o zgodności" +"No components yet. Select layers and press {keys}." = "Brak komponentów. Zaznacz warstwy i naciśnij {keys}." +"No conflicts." = "Brak konfliktów." +"No contributor data was built into this copy." = "Ta kopia nie zawiera danych o współtwórcach." +"No device" = "Brak urządzenia" +"No fonts match" = "Brak pasujących czcionek" +"No files or templates match your search" = "Żaden plik ani szablon nie pasuje do wyszukiwania" +"No join code here (the server is older than join codes, or this address can't be put in one): share the link instead." = "Brak kodu dołączenia (serwer jest starszy niż kody dołączenia lub ten adres nie może być zakodowany): zamiast tego udostępnij link." +"No layer changes to show." = "Brak zmian warstw do wyświetlenia." +"No libraries yet. Add a .figcraft file that was published as a library, or paste a team library's link." = "Brak bibliotek. Dodaj plik .figcraft opublikowany jako biblioteka lub wklej link do biblioteki zespołu." +"No list" = "Brak listy" +"No matching comments" = "Brak pasujących komentarzy" +"No matching components" = "Brak pasujących komponentów" +"No matching layers" = "Brak pasujących warstw" +"No matching styles" = "Brak pasujących stylów" +"No matching variables" = "Brak pasujących zmiennych" +"No mirroring" = "Brak odbicia lustrzanego" +"No model credits were built into this copy." = "Ta kopia nie zawiera informacji o modelach." +"No number variables fit here." = "Żadna zmienna liczbowa tutaj nie pasuje." +"No number variables yet. Create them in Local variables." = "Brak zmiennych liczbowych. Utwórz je w Zmiennych lokalnych." +"No other pages" = "Brak innych stron" +"No paints" = "Brak wypełnień/obrysów" +"No plugins on {server}." = "Brak wtyczek na {server}." +"No plugins yet. Import a development plugin from its figcraft-plugin.json." = "Brak wtyczek. Zaimportuj wtyczkę deweloperską z pliku figcraft-plugin.json." +"No preferred components" = "Brak preferowanych komponentów" +"No preset" = "Brak presetu" +"No saved versions yet. Save a version to mark a point in your work you can come back to." = "Brak zapisanych wersji. Zapisz wersję, aby oznaczyć punkt w pracy, do którego możesz wrócić." +"No scrolling" = "Brak przewijania" +"No shortcuts match" = "Brak pasujących skrótów" +"No status" = "Brak statusu" +"No string variables fit here." = "Żadna zmienna tekstowa tutaj nie pasuje." +"No string variables yet. Create them in Local variables." = "Brak zmiennych tekstowych. Utwórz je w Zmiennych lokalnych." +"No styles yet." = "Brak stylów." +"No styles yet. Click + to create one from the selection." = "Brak stylów. Kliknij +, aby utworzyć styl z zaznaczenia." +"No templates match your search" = "Żaden szablon nie pasuje do wyszukiwania" +"No text styles yet. Click + to create one from the selected text." = "Brak stylów tekstu. Kliknij +, aby utworzyć styl z zaznaczonego tekstu." +"No variables fit here." = "Żadne zmienne tutaj nie pasują." +"No variables yet. Create them in Local variables." = "Brak zmiennych. Utwórz je w Zmiennych lokalnych." +"Nobody else is here yet" = "Nikogo innego tu jeszcze nie ma" +"Noise" = "Szum" +"None" = "Brak" +"None." = "Brak." +"Normal" = "Zwykły" +"Not bound" = "Nieprzypisane" +"Not in a live session" = "Nie jesteś w sesji na żywo" +"Not in this font" = "Brak w tej czcionce" +"Not properties, kept as written: {names}" = "Nie są to właściwości, zachowano bez zmian: {names}" +"Not read yet" = "Jeszcze nieprzeczytane" +"Not shared" = "Nieudostępnione" +"Note {n}" = "Notatka {n}" +"Nothing changed since this was marked ready for dev." = "Nic się nie zmieniło od czasu oznaczenia jako gotowe do wdrożenia." +"Nothing here yet. Set a frame's status to list it." = "Nic tu jeszcze nie ma. Ustaw status ramki, aby ją wyświetlić." +"Nothing online yet. Use Share on a file to put it online and it is listed here." = "Brak plików online. Użyj Udostępnij na pliku, aby umieścić go online i wyświetlić tutaj." +"Nothing to export" = "Nic do wyeksportowania" +"Nov" = "Lis" +"Now" = "Teraz" +"Nudge (Shift: big nudge)" = "Krok przesunięcia (Shift: duży krok)" +"Nudge amount" = "Wielkość kroku przesunięcia" +"Number" = "Liczba" +"Number ↑" = "Liczba ↑" +"Number ↓" = "Liczba ↓" +"Numbered list" = "Lista numerowana" +"Object" = "Obiekt" +"Oct" = "Paź" +"Off" = "Wyłączone" +"Offered first when filling the slot (any content is allowed)" = "Proponowane w pierwszej kolejności przy wypełnianiu gniazda (dowolna zawartość jest dozwolona)" +"Offered first when swapping the instance (any component is allowed)" = "Proponowane w pierwszej kolejności przy podmianie instancji (dowolny komponent jest dozwolony)" +"Offset" = "Przesunięcie" +"Oldest first" = "Najpierw najstarsze" +"On click" = "Przy kliknięciu" +"On drag" = "Podczas przeciągania" +"On this page" = "Na tej stronie" +"One announcement in five common sizes: square, portrait, story, link and banner" = "Jedno ogłoszenie w pięciu popularnych rozmiarach: kwadrat, pion, relacja, link i baner" +"Online" = "Online" +"Online files" = "Pliki online" +"Online sharing is off: files are shared on the local network only." = "Udostępnianie online jest wyłączone: pliki są udostępniane tylko w sieci lokalnej." +"Only the first changed layers are listed." = "Wymieniono tylko pierwsze zmienione warstwy." +"Only the first {n} changed layers are listed." = "Wymieniono tylko pierwsze {n} zmienionych warstw." +"Only your threads" = "Tylko Twoje wątki" +"Opacity" = "Krycie" +"Opacity & blend" = "Nieprzezroczystość i mieszanie" +"Opacity 10%…90%, 0: 100%" = "Nieprzezroczystość 10%…90%, 0: 100%" +"Open" = "Otwórz" +"Open .figcraft, .svg or images" = "Otwórz .figcraft, .svg lub obrazy" +"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" +"Open link" = "Otwórz łącze" +"Open link {url}" = "Otwórz link: {url}" +"Open overlay" = "Otwórz nakładkę" +"Open prototype preview" = "Otwórz podgląd prototypu" +"Open variables ({count})" = "Otwórz zmienne ({count})" +"Opened as a new document" = "Otwarto jako nowy dokument" +"Opened “{name}” in a new tab" = "Otwarto „{name}” w nowej karcie" +"Opening the file…" = "Otwieranie pliku…" +"Opening {n} files as new files" = { one = "Otwieranie jako nowy plik", few = "Otwieranie {n} plików jako nowe pliki", many = "Otwieranie {n} plików jako nowe pliki", other = "Otwieranie {n} plików jako nowe pliki" } +"Open|comments" = "Open|comments" +"Options" = "Opcje" +"Orange" = "Pomarańczowy" +"Order" = "Kolejność" +"Other apps may not open every .fig file exported here." = "Inne aplikacje mogą nie otworzyć każdego pliku .fig wyeksportowanego tutaj." +"Other" = "Inne" +"Others lose access and keep their copy; the file stays on the server for you" = "Inni tracą dostęp i zachowują swoją kopię; plik pozostaje na serwerze dla Ciebie" +"Others type this under Share ▸ Join with a code" = "Inni wpisują to w menu Udostępnij ▸ Dołącz za pomocą kodu" +"Outline stroke" = "Przekształć obrys w ścieżkę" +"Outline text" = "Kontury tekstu" +"Outlines" = "Kontury" +"Outside" = "Na zewnątrz" +"Overflow" = "Przepełnienie (overflow)" +"Overlay" = "Nakładka" +"Overlay position" = "Pozycja nakładki" +"Overridden here (the collection it extends has another value)" = "Nadpisane tutaj (kolekcja, którą rozszerza, ma inną wartość)" +"Overrides" = "Nadpisania" +"Owner token" = "Token właściciela" +"Owner token copied" = "Skopiowano token właściciela" +"PDF print options" = "Opcje druku PDF" +"PDF print settings" = "Ustawienia druku PDF" +"Padding" = "Marginesy wewnętrzne" +"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ą" +"Pan the canvas" = "Przesuwaj płótno" +"Pan with nothing selected (Shift: farther)" = "Przesuwaj przy braku zaznaczenia (Shift: dalej)" +"Panels" = "Panele" +"Paper" = "Papier" +"Paragraph indent" = "Wcięcie akapitu" +"Paragraph indent applies to left-aligned text" = "Wcięcie akapitu dotyczy tekstu wyrównanego do lewej" +"Paragraph spacing" = "Odstęp akapitu" +"Pass through" = "Przepuszczanie" +"Paste a link (https://…)" = "Wklej link (https://…)" +"Paste a team library link (a view link)" = "Wklej link do biblioteki zespołu (link do podglądu)" +"Paste here" = "Wklej tutaj" +"Paste to replace" = "Wklej, aby zastąpić" +"Pasted design layers; {n} kinds of content were left out" = { one = "Wklejono warstwy projektu; pominięto {n} rodzaj zawartości", few = "Wklejono warstwy projektu; pominięto {n} rodzaje zawartości", many = "Wklejono warstwy projektu; pominięto {n} rodzajów zawartości", other = "Wklejono warstwy projektu; pominięto {n} rodzajów zawartości" } +"Pattern" = "Wzór" +"Pattern alignment" = "Wyrównanie wzoru" +"Pattern scale" = "Skala wzoru" +"Pattern source" = "Źródło wzoru" +"Pen" = "Pióro" +"Pencil" = "Ołówek" +"People in this file" = "Osoby w tym pliku" +"People join from anywhere with a code. The file is kept on {server} while you share it." = "Użytkownicy dołączają z dowolnego miejsca za pomocą kodu. Plik jest przechowywany na {server} podczas udostępniania." +"People on your network join with a code. Nothing goes through a server." = "Użytkownicy w Twojej sieci dołączają za pomocą kodu. Nic nie przechodzi przez serwer zewnętrzny." +"Phone" = "Telefon" +"Pick color" = "Wybierz kolor" +"Pick color (I)" = "Wybierz kolor (I)" +"Pick one font family first" = "Najpierw wybierz jedną rodzinę czcionek" +"Pick the Frame tool, then drag on the canvas or choose a device size" = "Wybierz narzędzie Ramka, a następnie przeciągnij po płótnie lub wybierz rozmiar urządzenia" +"Pin a property" = "Przypnij właściwość" +"Pinch" = "Uszczypnięcie" +"Pixel grid" = "Siatka pikseli" +"Pixel preview 1x" = "Podgląd pikselowy 1x" +"Pixel preview 2x" = "Podgląd pikselowy 2x" +"Place a shape or frame (its tool chosen)" = "Umieść kształt lub ramkę (wybrane narzędzie)" +"Place automatically" = "Umieść automatycznie" +"Place image" = "Umieść obraz" +"Plugin" = "Wtyczka" +"Plugins run in FigCraft's sandbox, without files, network or clock. Install only plugins you trust." = "Wtyczki działają w piaskownicy FigCraft, bez dostępu do plików, sieci czy zegara. Instaluj tylko zaufane wtyczki." +"Plugins" = "Wtyczki" +"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" +"Polygons" = "Wielokąty" +"Port" = "Port" +"Position" = "Położenie" +"Post" = "Opublikuj" +"Preferences" = "Preferencje" +"Preferred" = "Preferowane" +"Present" = "Prezentuj" +"Present or preview" = "Prezentuj lub wyświetl podgląd" +"Present this flow" = "Prezentuj ten przepływ" +"Presentation" = "Prezentacja" +"Presentation slides" = "Slajdy prezentacji" +"Presenting: comments" = "Prezentacja: komentarze" +"Presenting: cycle the size options" = "Prezentacja: przełączaj opcje rozmiaru" +"Presenting: full screen" = "Prezentacja: pełny ekran" +"Presenting: next frame" = "Prezentacja: następna ramka" +"Presenting: previous frame" = "Prezentacja: poprzednia ramka" +"Presenting: restart" = "Prezentacja: uruchom ponownie" +"Preserve scroll position" = "Zachowaj pozycję przewijania" +"Presets" = "Ustawienia wstępne" +"Press a key…" = "Naciśnij klawisz…" +"Playground" = "Piaskownica (Playground)" +"Preview" = "Podgląd" +"Preview this flow" = "Podgląd tego przepływu" +"Preview this frame" = "Podgląd tej ramki" +"Previous frame" = "Poprzednia klatka" +"Previous ready for dev" = "Poprzedni gotowy do wdrożenia" +"Progressive" = "Progresywny" +"Properties" = "Właściwości" +"Prototype" = "Prototyp" +"Prototype preview" = "Podgląd prototypu" +"Prototype settings" = "Ustawienia prototypu" +"Publish" = "Publikuj" +"Publish changes" = "Publikuj zmiany" +"Publish to another server…" = "Publikuj na innym serwerze…" +"Publish to server" = "Publikuj na serwerze" +"Published version {v}" = "Opublikowano wersję {v}" +"Published version {v} to the server" = "Opublikowano wersję {v} na serwerze" +"Published version {v}: save the file so other files can use it" = "Opublikowano wersję {v}: zapisz plik, aby inne pliki mogły go używać" +"Published · {people}" = "Opublikowano · {people}" +"Publishing to a server needs the desktop app; in the browser you can join a server's files" = "Publikowanie na serwerze wymaga aplikacji desktopowej; w przeglądarce możesz dołączać do plików serwera" +"Publishing with the server's key." = "Publikowanie przy użyciu klucza serwera." +"Publishing…" = "Publikowanie…" +"Push" = "Wypychanie" +"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ń" +"Ratio" = "Proporcje" +"Reading the library…" = "Odczytywanie biblioteki…" +"Ready for dev" = "Gotowe do wdrożenia" +"Real name" = "Imię i nazwisko" +"Recent" = "Ostatnie" +"Recent files" = "Ostatnie pliki" +"Recents" = "Ostatnie" +"Recovered {n} unsaved files after the app quit unexpectedly: save them to keep them" = { one = "Odzyskano niezapisany plik po nieoczekiwanym zamknięciu programu: zapisz go, aby go zachować", few = "Odzyskano {n} niezapisane pliki po nieoczekiwanym zamknięciu programu: zapisz je, aby je zachować", many = "Odzyskano {n} niezapisanych plików po nieoczekiwanym zamknięciu programu: zapisz je, aby je zachować", other = "Odzyskano {n} niezapisanych plików po nieoczekiwanym zamknięciu programu: zapisz je, aby je zachować" } +"Rectangle" = "Prostokąt" +"Rectangles" = "Prostokąty" +"Red" = "Czerwony" +"Reduce motion" = "Ogranicz ruch" +"Refraction" = "Załamanie światła (refrakcja)" +"Regular expression" = "Wyrażenie regularne" +"Remove" = "Usuń" +"Remove all" = "Usuń wszystko" +"Remove annotation" = "Usuń adnotację" +"Remove auto layout" = "Usuń Auto layout" +"Remove dev resource" = "Usuń zasób programistyczny" +"Remove export setting" = "Usuń ustawienie eksportu" +"Remove from recents" = "Usuń z ostatnich" +"Remove from server" = "Usuń z serwera" +"Remove from server…" = "Usuń z serwera…" +"Remove guide" = "Usuń linię pomocniczą" +"Remove interaction" = "Usuń interakcję" +"Remove library from this file" = "Usuń bibliotekę z tego pliku" +"Remove min and max" = "Usuń min. i maks." +"Remove min and max height" = "Usuń minimalną i maksymalną wysokość" +"Remove min and max width" = "Usuń minimalną i maksymalną szerokość" +"Remove regions" = "Usuń obszary" +"Remove slot" = "Usuń gniazdo" +"Remove starting point" = "Usuń punkt startowy" +"Remove stop" = "Usuń punkt" +"Remove text from path" = "Usuń tekst ze ścieżki" +"Remove the else and its actions" = "Usuń warunek przeciwny (else) i jego akcje" +"Remove this condition and its actions" = "Usuń ten warunek i jego akcje" +"Remove this file from the server? It can't be shared or opened from there again. Your copy stays open here." = "Usunąć ten plik z serwera? Nie będzie można go już stamtąd udostępnić ani otworzyć. Twoja kopia pozostanie otwarta tutaj." +"Remove {name} from session" = "Usuń {name} z sesji" +"Remove {name} from {server}?" = "Usunąć {name} z {server}?" +"Removed" = "Usunięto" +"Removed from the server" = "Usunięto z serwera" +"Removed since it was marked" = "Usunięto od momentu oznaczenia" +"Rename" = "Zmień nazwę" +"Rename duplicated layers" = "Zmień nazwę powielonych warstw" +"Rename folder" = "Zmień nazwę folderu" +"Rename group" = "Zmień nazwę grupy" +"Rename layers" = "Zmień nazwę warstw" +"Rename mode" = "Zmień nazwę trybu" +"Rename on the server" = "Zmień nazwę na serwerze" +"Rename on the server…" = "Zmień nazwę na serwerze…" +"Rename page" = "Zmień nazwę strony" +"Rename to" = "Zmień nazwę na" +"Replace all" = "Zamień wszystko" +"Replace mixed export settings" = "Zastąp zróżnicowane ustawienia eksportu" +"Replace with" = "Zamień na" +"Replace with a new interaction" = "Zastąp nową interakcją" +"Reply" = "Odpowiedz" +"Report" = "Raport" +"Report this file…" = "Zgłoś ten plik…" +"Reset" = "Resetuj" +"Reset adjustments" = "Zresetuj korekty" +"Reset all changes" = "Zresetuj wszystkie zmiany" +"Reset all overrides on this layer" = "Zresetuj wszystkie nadpisania na tej warstwie" +"Reset overrides" = "Zresetuj nadpisania" +"Reset slot" = "Zresetuj gniazdo" +"Reset this override" = "Zresetuj to nadpisanie" +"Reset to style" = "Przywróć styl" +"Reset to the inherited value" = "Przywróć wartość dziedziczoną" +"Resize by the nudge amount" = "Zmień rozmiar o wielkość kroku przesunięcia" +"Resize frames to the device's screen (or the window without a device): their constraints and auto layout respond" = "Dopasuj ramki do ekranu urządzenia (lub okna bez urządzenia): ich ograniczenia i automatyczny układ odpowiednio zareagują" +"Resize to fit" = "Dopasuj rozmiar" +"Resizing" = "Zmiana rozmiaru" +"Resolve" = "Rozwiąż" +"Resolve change" = "Rozwiąż zmianę" +"Resolved" = "Rozwiązany" +"Resources" = "Zasoby" +"Responsive" = "Responsywny" +"Restart" = "Uruchom ponownie" +"Restore" = "Przywróć" +"Restore main component" = "Przywróć główny komponent" +"Restore this version" = "Przywróć tę wersję" +"Restored “{name}”" = "Przywrócono „{name}”" +"Reverse the order" = "Odwróć kolejność" +"Reversed triangle" = "Odwrócony trójkąt" +"Review and merge" = "Przejrzyj i scal" +"Ribbon" = "Wstążka" +"Right" = "Do prawej" +"Right side" = "Prawa strona" +"Right side width" = "Szerokość prawej strony" +"Rotate 90°" = "Obróć o 90°" +"Rotate 90° right" = "Obróć o 90° w prawo" +"Rotate to landscape" = "Obróć do orientacji poziomej" +"Rotate to portrait" = "Obróć do orientacji pionowej" +"Rotation" = "Obrót" +"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" +"Row span" = "Rozpiętość wierszy" +"Rows" = "Wiersze" +"Rows {count}" = "Wiersze: {count}" +"Rulers" = "Linijki" +"Run" = "Uruchom" +"Run {label}" = "Uruchom {label}" +"Save files automatically" = "Zapisuj pliki automatycznie" +"Search templates" = "Szukaj szablonów" +"See all templates" = "Zobacz wszystkie szablony" +"Select layers and they space themselves as their content changes" = "Zaznacz warstwy, a będą automatycznie zachowywać odstępy przy zmianie zawartości" +"SHA-256: {sha}…" = "SHA-256: {sha}…" +"Same as layer" = "Taki sam jak warstwa" +"Saturation" = "Nasycenie" +"Save" = "Zapisz" +"Save as" = "Zapisz jako" +"Save as preset…" = "Zapisz jako preset…" +"Save version {key}" = "Zapisz wersję {key}" +"Saved" = "Zapisano" +"Saved {name}" = "Zapisano: {name}" +"Say something" = "Napisz coś" +"Scale" = "Skaluj" +"Scatter" = "Rozproszenie" +"Scoping: where pickers offer it" = "Zakres: gdzie próbniki go oferują" +"Screen" = "Ekran" +"Scroll behavior" = "Zachowanie przewijania" +"Scroll to" = "Przewiń do" +"Scroll to layer" = "Przewiń do warstwy" +"Scroll with parent" = "Przewijaj z rodzicem" +"Search plugins" = "Szukaj wtyczek" +"Search" = "Szukaj" +"Search actions, layers and tools" = "Szukaj akcji, warstw i narzędzi" +"Search assets" = "Szukaj zasobów" +"Search comments" = "Szukaj komentarzy" +"Search files and templates" = "Szukaj plików i szablonów" +"Search results" = "Wyniki wyszukiwania" +"Search fonts" = "Szukaj czcionek" +"Search layers" = "Szukaj warstw" +"Search shortcuts" = "Szukaj skrótów" +"Section" = "Sekcja" +"Section color" = "Kolor sekcji" +"Sections" = "Sekcje" +"Select a frame and add a starting point to present it as a flow." = "Wybierz ramkę i dodaj punkt początkowy, aby zaprezentować ją jako przepływ." +"Select a frame to preview it" = "Wybierz ramkę, aby wyświetlić jej podgląd" +"Select a layer to see its properties and code" = "Wybierz warstwę, aby zobaczyć jej właściwości i kod" +"Select all" = "Zaznacz wszystko" +"Select all with same" = "Zaznacz wszystko z takim samym" +"Select layer" = "Zaznacz warstwę" +"Select layers to apply the style to" = "Zaznacz warstwy, do których chcesz zastosować styl" +"Select layers with this color" = "Zaznacz warstwy z tym kolorem" +"Select overlapping shapes" = "Zaznacz nakładające się kształty" +"Select points to edit their position, radius and mirroring" = "Zaznacz punkty, aby edytować ich pozycję, promień i odbicie lustrzane" +"Select slot" = "Zaznacz gniazdo (slot)" +"Selection colors" = "Kolory zaznaczenia" +"Selection" = "Zaznaczenie" +"Selection box, {w} by {h} at {x}, {y}: arrows move it, Shift+arrows or {cmd}+arrows size it, Enter selects, Escape cancels" = "Ramka zaznaczenia, {w} × {h} w ({x}, {y}): strzałki przesuwają, Shift+strzałki lub {cmd}+strzałki zmieniają rozmiar, Enter zatwierdza, Escape anuluje" +"Selection box: arrows move it, Shift+arrows or {cmd}+arrows size it, Enter selects, Escape cancels" = "Ramka zaznaczenia: strzałki przesuwają, Shift+strzałki lub {cmd}+strzałki zmieniają rozmiar, Enter zatwierdza, Escape anuluje" +"Send backward" = "Przenieś do tyłu" +"Send report" = "Wyślij zgłoszenie" +"Send to back" = "Przenieś na spód" +"Sep" = "Wrz" +"Server address (ws://host:47900/)" = "Adres serwera (ws://host:47900/)" +"Server address" = "Adres serwera" +"Server for online sharing" = "Serwer do udostępniania online" +"Set as default" = "Ustaw jako domyślne" +"Set variable" = "Ustaw zmienną" +"Set variable mode" = "Ustaw tryb zmiennej" +"Set variable {name}" = "Ustaw zmienną {name}" +"Set {name} to {value}" = "Ustaw {name} na {value}" +"Shadows" = "Cienie" +"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" +"Share: live session, export" = "Udostępnianie: sesja na żywo, eksport" +"Shared" = "Udostępniono" +"Shared: copy its code from the file's menu" = "Udostępniono: skopiuj kod z menu pliku" +"Sharing a file needs the desktop app; here you can join one." = "Udostępnianie pliku wymaga aplikacji desktopowej; tutaj możesz tylko dołączyć." +"Sharing on the local network (online sharing is off: no server is set in Preferences)." = "Udostępnianie w sieci lokalnej (udostępnianie online wyłączone: brak serwera w Preferencjach)." +"Sharing on your network · {people}" = "Udostępnianie w Twojej sieci · {people}" +"Sharing online · {people}" = "Udostępnianie online · {people}" +"Sharing stopped: the file stays on the server, open only to you" = "Zatrzymano udostępnianie: plik pozostaje na serwerze, otwarty tylko dla Ciebie" +"Show" = "Pokaż" +"Show all templates" = "Pokaż wszystkie szablony" +"Show as grid" = "Pokaż jako siatkę" +"Show as list" = "Pokaż jako listę" +"Show autosaves" = "Pokaż automatyczne zapisy" +"Show behind transparent areas" = "Pokaż za przezroczystymi obszarami" +"Show comments (Shift+C)" = "Pokaż komentarze (Shift+C)" +"Show device frame" = "Pokaż ramkę urządzenia" +"Show dimensions on objects" = "Pokaż wymiary na obiektach" +"Show hotspot hints" = "Pokaż podpowiedzi obszarów klikalnych (hotspots)" +"Show in Explorer" = "Pokaż w Eksploratorze" +"Show in Finder" = "Pokaż w Finderze" +"Show in folder" = "Pokaż w folderze" +"Show only this on the canvas" = "Pokaż tylko ten element na płótnie" +"Show open" = "Pokaż otwarte" +"Show section contents" = "Pokaż zawartość sekcji" +"Show this layer" = "Pokaż tę warstwę" +"Show usage" = "Pokaż użycie" +"Show/Hide" = "Pokaż/Ukryj" +"Show/Hide UI" = "Pokaż/Ukryj interfejs" +"Side by side" = "Obok siebie" +"Sidebar" = "Panel boczny" +"Simplify stroke" = "Uprość obrys" +"Size" = "Rozmiar" +"Size jitter" = "Rozrzut rozmiaru" +"Skip ink" = "Pomiń atrament" +"Slashed zero" = "Przekreślone zero" +"Slice" = "Wycinek" +"Slices" = "Plasterki" +"Slide in" = "Wsuń slajd" +"Slide out" = "Wysuń slajd" +"Slot" = "Gniazdo (slot)" +"Slot content" = "Zawartość gniazda" +"Small capitals" = "Kapitaliki" +"Small caps" = "Kapitaliki" +"Smart animate" = "Inteligentna animacja (Smart animate)" +"Snap to pixel grid" = "Przyciągaj do siatki pikseli" +"Social and slides" = "Social media i slajdy" +"Social media" = "Media społecznościowe" +"Social post sizes" = "Rozmiary postów w mediach społecznościowych" +"Soft light" = "Łagodne światło" +"Solid" = "Jednolity" +"Some variants have the same property values: give each one a unique combination" = "Niektóre warianty mają takie same wartości właściwości: nadaj każdemu unikalną kombinację" +"Sort" = "Sortuj" +"Sort by" = "Sortuj według" +"Sort by date" = "Sortuj według daty" +"Sort by page" = "Sortuj według strony" +"Sort by {column}" = "Sortuj według: {column}" +"Space around" = "Równe odstępy z marginesem (Space around)" +"Space between" = "Równe odstępy między elementami (Space between)" +"Space drag" = "Przesuwanie spacją" +"Space evenly" = "Rozmieść równomiernie" +"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" +"Stars" = "Gwiazdy" +"Start" = "Uruchom" +"Start ascending from / stop descending at" = "Początek narastania od / koniec opadania w" +"Start from a blank canvas" = "Zacznij od pustego płótna" +"Start from a template" = "Zacznij od szablonu" +"Start on path" = "Początek na ścieżce" +"Start point" = "Punkt początkowy" +"Start sharing" = "Rozpocznij udostępnianie" +"Start sharing with the Share button, then pass on the code it gives" = "Rozpocznij udostępnianie przyciskiem Udostępnij, a następnie przekaż wygenerowany kod" +"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)" +"Stroke" = "Obrys" +"Stroke style" = "Styl obrysu" +"Stroke weight" = "Grubość obrysu" +"Strokes" = "Obrysy" +"Style" = "Styl" +"Styles" = "Style" +"Stylistic set 1" = "Zestaw stylistyczny 1" +"Stylistic set 2" = "Zestaw stylistyczny 2" +"Stylistic set 3" = "Zestaw stylistyczny 3" +"Stylistic set 4" = "Zestaw stylistyczny 4" +"Stylistic set 5" = "Zestaw stylistyczny 5" +"Subtract" = "Odejmij" +"Subtracts" = "Odejmuje" +"Suffix" = "Przyrostek" +"Suspend snapping while dragging" = "Zawieś przyciąganie podczas przeciągania" +"Swap instance" = "Podmień instancję" +"Swap library: use another library's assets of the same names" = "Podmień bibliotekę: użyj zasobów innej biblioteki o tych samych nazwach" +"Swap orientation" = "Zmień orientację" +"Swap overlay" = "Podmień nakładkę" +"Swap to {name}" = "Podmień na {name}" +"Swap width and height" = "Zamień szerokość i wysokość" +"Swapped instance" = "Podmieniono instancję" +"Swapped {n} assets" = { one = "Podmieniono {n} zasób", few = "Podmieniono {n} zasoby", many = "Podmieniono {n} zasobów", other = "Podmieniono {n} zasobów" } +"Swapped {n} assets; {missed} had no match and still use the old library" = { one = "Podmieniono {n} zasób; {missed} nie ma dopasowania i nadal używa starej biblioteki", few = "Podmieniono {n} zasoby; {missed} nie ma dopasowania i nadal używa starej biblioteki", many = "Podmieniono {n} zasobów; {missed} nie ma dopasowania i nadal używa starej biblioteki", other = "Podmieniono {n} zasobów; {missed} nie ma dopasowania i nadal używa starej biblioteki" } +"Sweep" = "Przemiatanie (sweep)" +"System" = "Systemowy" +"Tab / Shift+Tab" = "Tab / Shift+Tab" +"Table" = "Tabela" +"Tablet" = "Tablet" +"Tabular numbers" = "Cyfry tabelaryczne" +"Take the values from the first selected layer" = "Pobierz wartości z pierwszej zaznaczonej warstwy" +"Taper" = "Zwężanie" +"Taper both ends" = "Zwężaj oba końce" +"Tell the server's operator what is wrong with it" = "Poinformuj administratora serwera, co jest nie tak" +"Temperature" = "Temperatura" +"Template" = "Szablon" +"Templates" = "Szablony" +"Terms" = "Warunki" +"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" +"Texture" = "Tekstura" +"The canvas previews colors in sRGB; exported PNG, JPG and WebP files are tagged Display P3." = "Płótno wyświetla podgląd kolorów w sRGB; wyeksportowane pliki PNG, JPG i WebP są oznaczane profilem Display P3." +"The destination document is unavailable. Nothing was imported." = "Dokument docelowy jest niedostępny. Nic nie zostało zaimportowane." +"The destination document was closed. Nothing was imported." = "Dokument docelowy został zamknięty. Nic nie zostało zaimportowane." +"The destination page was removed. Nothing was imported." = "Strona docelowa została usunięta. Nic nie zostało zaimportowane." +"The file is deleted from the server: nobody can open or join it from there again, and this can't be undone. Copies that are open stay with whoever has them." = "Plik został usunięty z serwera: nikt nie może go już stamtąd otworzyć ani do niego dołączyć, a tej operacji nie można cofnąć. Otwarte kopie pozostają u ich posiadaczy." +"The file now holds another library" = "Plik zawiera teraz inną bibliotekę" +"The file now holds another library (\"{name}\")" = "Plik zawiera teraz inną bibliotekę („{name}”)" +"The files you shared or published online from this computer. They stay on their server until you remove them." = "Pliki udostępnione lub opublikowane online z tego komputera. Pozostają na serwerze do momentu ich usunięcia." +"The link carries the session's secret: share it only with people you invite." = "Link zawiera sekret sesji: udostępniaj go tylko osobom, które zapraszasz." +"The name in your online files and on the server. The document inside keeps its own name." = "Nazwa w Twoich plikach online i na serwerze. Dokument wewnątrz zachowuje własną nazwę." +"The selection uses several text styles: click to apply one" = "Zaznaczenie używa kilku stylów tekstu: kliknij, aby zastosować jeden" +"The server deletes this file if nobody opens it before {date}." = "Serwer usunie ten plik, jeśli nikt go nie otworzy przed {date}." +"The whole file" = "Cały plik" +"Theme" = "Motyw" +"There's no destination for that action" = "Brak miejsca docelowego dla tej akcji" +"Thickness" = "Grubość" +"This branch can't be reviewed: {e}" = "Tej gałęzi nie można przejrzeć: {e}" +"This branch was merged or archived." = "Ta gałąź została scalona lub zarchiwizowana." +"This computer only" = "Tylko ten komputer" +"This computer remembers the owner token, so the file can be managed from the home screen's Online list. Copy it to manage the file from somewhere else: it is a password for this file and won't be shown again." = "Ten komputer pamięta token właściciela, więc plik może być zarządzany z listy Online na ekranie głównym. Skopiuj go, aby zarządzać plikiem z innego miejsca: jest to hasło do tego pliku i nie zostanie ponownie wyświetlone." +"This condition is edited as an expression" = "Ten warunek jest edytowany jako wyrażenie" +"This file" = "Ten plik" +"This file has unsaved changes" = "Ten plik ma niezapisane zmiany" +"This file uses fonts that aren't installed on this computer. Their text is drawn in a fallback font until you install them." = "Ten plik używa czcionek, które nie są zainstalowane na tym komputerze. Do czasu ich zainstalowania tekst jest wyświetlany czcionką zastępczą." +"This file is on {server}, open only to you. Share it to let others in with a new code." = "Ten plik znajduje się na {server}, otwarty tylko dla Ciebie. Udostępnij go, aby wpuścić innych nowym kodem." +"This frame is new on the branch." = "Ta ramka jest nowa w gałęzi." +"This frame isn't on the branch." = "Tej ramki nie ma w gałęzi." +"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" +"Tint" = "Odcień barwny" +"Title" = "Tytuł" +"Title case" = "Jak W Tytule" +"To share this library with your team, publish the file to a server (Share ▸ Publish to server…), then publish the library there." = "Aby udostępnić tę bibliotekę zespołowi, opublikuj plik na serwerze (Udostępnij ▸ Publikuj na serwerze…), a następnie opublikuj tam bibliotekę." +"Toggle" = "Przełącz" +"Toggle light/dark theme" = "Przełącz motyw jasny/ciemny" +"Toggle visibility" = "Przełącz widoczność" +"Tools" = "Narzędzia" +"Top" = "Góra" +"Top & bottom" = "Góra i dół" +"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" +"Truncate text" = "Utnij tekst" +"Truncate with an ellipsis" = "Utnij tekst z wielokropkiem (…)" +"Try again" = "Spróbuj ponownie" +"Try another search or filter." = "Wypróbuj inne wyszukiwanie lub filtr." +"Try another search, or browse templates." = "Spróbuj innego wyszukiwania lub przejrzyj szablony." +"Try another word, or look at every kind." = "Wypróbuj inne słowo lub przejrzyj wszystkie typy." +"Turn a layer into a component, reuse it, and change every copy at once" = "Przekształć warstwę w komponent, używaj jej ponownie i modyfikuj wszystkie kopie naraz" +"Two digits" = "Dwie cyfry" +"Type" = "Tekst" +"Type settings" = "Ustawienia tekstu" +"Type the join code you were given, or paste an invite link." = "Wpisz otrzymany kod dołączenia lub wklej link z zaproszeniem." +"Typography" = "Typografia" +"Underline" = "Podkreślenie" +"Undo takes back only your own changes; what others changed since stays." = "Cofanie odwołuje tylko Twoje własne zmiany; modyfikacje wprowadzone przez innych pozostają." +"Ungroup" = "Rozgrupuj" +"Uniform" = "Jednolity" +"Union" = "Suma zbiorów" +"Unions" = "Sumy" +"Unknown — treated as sRGB" = "Nieznany — traktowany jako sRGB" +"Unread" = "Nieprzeczytane" +"Unresolve" = "Cofnij rozwiązanie" +"Unsaved changes" = "Niezapisane zmiany" +"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ę" +"Update plugin" = "Zaktualizuj wtyczkę" +"Update" = "Aktualizuj" +"Update from main" = "Zaktualizuj z gałęzi głównej" +"Update from selection" = "Zaktualizuj z zaznaczenia" +"Updated {components} and {styles}" = "Zaktualizowano {components} i {styles}" +"Updated {name}" = "Zaktualizowano {name}" +"Updating the graphics driver often helps. To use OpenGL only, start FigCraft from a Command Prompt with:" = "Aktualizacja sterownika graficznego często pomaga. Aby używać tylko OpenGL, uruchom FigCraft z wiersza poleceń za pomocą:" +"Updating the graphics driver often helps. WGPU_BACKEND (vulkan, gl, metal) names another graphics API to try." = "Aktualizacja sterownika graficznego często pomaga. Zmienna WGPU_BACKEND (vulkan, gl, metal) pozwala wybrać inne graficzne API do wypróbowania." +"Uploads this file to a FigCraft server and keeps this tab in sync with it. You get a link to edit and a link to view." = "Przesyła ten plik na serwer FigCraft i synchronizuje tę kartę na bieżąco. Otrzymujesz link do edycji oraz link do podglądu." +"Uppercase" = "WIELKIE LITERY" +"Use a fixed value" = "Użyj stałej wartości" +"Use an expression, e.g. count + 1" = "Użyj wyrażenia, np. count + 1" +"Use as mask" = "Użyj jako maski" +"Use auto layout" = "Użyj automatycznego układu" +"Use number keys for opacity" = "Użyj klawiszy numerycznych dla nieprzezroczystości" +"Use the .figcraft or .fig extension." = "Użyj rozszerzenia .figcraft lub .fig." +"Use the layer's fill" = "Użyj wypełnienia warstwy" +"Used in {files} · {instances}" = "Używane w {files} · {instances}" +"Username" = "Nazwa użytkownika" +"Value" = "Wartość" +"Uses variables: each layer shows them in its own mode" = "Używa zmiennych: każda warstwa wyświetla je we własnym trybie" +"Variable" = "Zmienna" +"Variable collections" = "Kolekcje zmiennych" +"Variable modes" = "Tryby zmiennych" +"Variable width" = "Zmienna szerokość" +"Variables" = "Zmienne" +"Variables JSON…" = "JSON ze zmiennymi…" +"Variables store reusable values (colours, numbers, strings, booleans) with modes such as Light and Dark." = "Zmienne przechowują wartości wielokrotnego użytku (kolory, liczby, teksty, wartości logiczne) z trybami takimi jak Jasny i Ciemny." +"Variant" = "Wariant" +"Vector" = "Wektor" +"Vectors" = "Wektory" +"Version" = "Wersja" +"Version history" = "Historia wersji" +"Version {version}" = "Wersja {version}" +"Version {v} · {components} · {styles} · {variables}" = "Wersja {v} · {components} · {styles} · {variables}" +"Vertical" = "Pionowo" +"Vertical align" = "Wyrównanie pionowe" +"Vertical hex" = "Pionowy sześciokąt" +"Vertical padding" = "Marginesy pionowe" +"Vertical spacing" = "Odstępy pionowe" +"Vertical trim" = "Przycinanie pionowe (vertical trim)" +"View" = "Widok" +"View link" = "Link do podglądu" +"View link copied" = "Skopiowano link do podglądu" +"View only" = "Tylko podgląd" +"View only: you see every change as it happens but can't edit. Leave to keep a copy you can edit." = "Tylko podgląd: widzisz każdą zmianę w czasie rzeczywistym, ale nie możesz edytować. Opuść sesję, aby zachować kopię do edycji." +"Viewing “{name}”" = "Podglądanie „{name}”" +"Violet" = "Fiolet" +"Visibility" = "Widoczność" +"Visible" = "Widoczne" +"Vivid" = "Żywe kolory" +"WGPU_BACKEND is set and names the graphics API to use; unsetting it lets FigCraft choose." = "Zmienna WGPU_BACKEND jest ustawiona i określa używane API graficzne; jej usunięcie pozwala FigCraft dokonać wyboru." +"Warm" = "Ciepły" +"Watch" = "Zegarek" +"Wavy" = "Falisty" +"Web" = "Internet" +"Websites" = "Strony internetowe" +"WebSocket URL" = "Adres WebSocket URL" +"WebSocket URL copied" = "Skopiowano adres WebSocket URL" +"Wedge" = "Klin" +"Weight" = "Grubość" +"What you try here changes this preview and the code below, not the file" = "To, co wypróbujesz tutaj, zmienia ten podgląd i kod poniżej, a nie plik" +"What changed since it was marked ready for dev" = "Co się zmieniło od czasu oznaczenia jako gotowe do wdrożenia" +"What is wrong with this file? The server's operator gets your report." = "Co jest nie tak z tym plikiem? Administrator serwera otrzyma Twoje zgłoszenie." +"What it's for" = "Do czego to służy" +"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." = "Nie wiadomo, w stosunku do czego zaszła zmiana: ramka zmieniła się, zanim FigCraft zaczął zapisywać kopię ramek gotowych do wdrożenia. Rozwiąż zmianę (lub oznacz ponownie jako gotowe), aby móc porównywać od teraz." +"While hovering" = "Podczas najechania" +"While pressing" = "Podczas naciskania" +"White" = "Biały" +"Who runs it, its terms and how long it keeps files" = "Kto zarządza serwerem, jego regulamin i czas przechowywania plików" +"Why it changed" = "Dlaczego to się zmieniło" +"Widget" = "Widżet" +"Widgets" = "Widżety" +"Width" = "Szerokość" +"Width point" = "Punkt szerokości" +"Width profile" = "Profil szerokości" +"Wireframe kit" = "Zestaw makiet (Wireframe kit)" +"With the layers' export settings" = "Z ustawieniami eksportu warstw" +"Wrap" = "Owinięcie" +"Wrap in new section" = "Zawiń w nową sekcję" +"Yellow" = "Żółty" +"Yesterday" = "Wczoraj" +"You" = "Ty" +"You'll get the shared file as a new tab and edit it together." = "Otrzymasz udostępniony plik jako nową kartę i będziecie go edytować wspólnie." +"You're spotlighting: everyone follows your view" = "Wyróżniasz swój widok (spotlight): wszyscy podążają za Twoim widokiem" +"Your copy of the file stays open" = "Twoja kopia pliku pozostaje otwarta" +"Your copy stays open; the file stays on the server" = "Twoja kopia pozostaje otwarta; plik pozostaje na serwerze" +"Your name" = "Twoje imię i nazwisko" +"Your name in comments" = "Twoje imię w komentarzach" +"Your system asks for reduced motion: animations stay off." = "Twój system preferuje ograniczony ruch: animacje pozostają wyłączone." +"Z" = "Z" +"Z to A" = "Od Z do A" +"Zoom" = "Powiększenie" +"Zoom around the pointer" = "Powiększaj wokół kursora" +"Zoom device to fill window" = "Dopasuj urządzenie do okna z powiększeniem" +"Zoom in" = "Powiększ" +"Zoom out" = "Pomniejsz" +"Zoom to 100%" = "Powiększ do 100%" +"Zoom to 200%" = "Powiększ do 200%" +"Zoom to 50%" = "Zmniejsz do 50%" +"Zoom to fit" = "Dopasuj do okna" +"Zoom to selection" = "Powiększaj do zaznaczenia" +"Zoom tool: click to zoom in, drag an area" = "Narzędzie powiększenia: kliknij, aby przybliżyć, przeciągnij obszar" +"Zoom tool: zoom out" = "Narzędzie powiększenia: oddal" +"and {n} more" = { one = "i jeszcze {n}", few = "i jeszcze {n}", many = "i jeszcze {n}", other = "i jeszcze {n}" } +"bleed marks" = "znaczniki spadu" +"by {author}" = "autor: {author}" +"call any command its access allows" = "wywołaj dowolne polecenie, na które zezwala dostęp" +"call only: {commands}" = "wywołaj tylko: {commands}" +"can edit the document" = "może edytować dokument" +"conditional prototype actions nested too deeply were skipped" = "zbyt głęboko zagnieżdżone warunkowe akcje prototypu zostały pominięte" +"conditional prototype actions with conditions that can't be evaluated were skipped" = "warunkowe akcje prototypu z warunkami niemożliwymi do obliczenia zostały pominięte" +"connector label background was skipped: style data is missing" = "tło etykiety łącznika zostało pominięte: brak danych stylu" +"connector label was skipped: background bounds are invalid" = "etykieta łącznika została pominięta: nieprawidłowe granice tła" +"connector label was skipped: background transform is invalid" = "etykieta łącznika została pominięta: nieprawidłowe przekształcenie tła" +"connector label was skipped: composed text transform is invalid" = "etykieta łącznika została pominięta: nieprawidłowe przekształcenie tekstu złożonego" +"connector label was skipped: derived background bounds are missing" = "etykieta łącznika została pominięta: brak wyznaczonych granic tła" +"connector label was skipped: derived text bounds are missing" = "etykieta łącznika została pominięta: brak wyznaczonych granic tekstu" +"connector label was skipped: text bounds are invalid" = "etykieta łącznika została pominięta: nieprawidłowe granice tekstu" +"connector label was skipped: text style data is missing" = "etykieta łącznika została pominięta: brak danych stylu tekstu" +"connector label was skipped: text transform is invalid" = "etykieta łącznika została pominięta: nieprawidłowe przekształcenie tekstu" +"connector labels were skipped" = "etykiety łączników zostały pominięte" +"connector layers import as static frame/vector snapshots with labels when present; live endpoint bindings are not retained" = "warstwy łączników są importowane jako statyczne ramki/wektory z etykietami; aktywne punkty końcowe nie są zachowywane" +"connector snapshot vector data is missing; connector was left empty" = "brak danych wektorowych łącznika; łącznik pozostał pusty" +"connectors whose ends can't be placed are empty" = "łączniki, których końców nie można umieścić, pozostają puste" +"crop marks" = "Znaczniki przycięcia" +"damaged vector geometry was skipped" = "uszkodzona geometria wektorowa została pominięta" +"damaged vector networks import as outlines" = "uszkodzone sieci wektorowe są importowane jako kontury" +"development: {path}" = "wersja rozwojowa: {path}" +"document children other than pages were skipped" = "elementy podrzędne dokumentu inne niż strony zostały pominięte" +"duplicate grid track sizing entries use equal tracks" = "zduplikowane wpisy rozmiaru siatki używają równych pasów" +"e.g. count + 1" = "np. count + 1" +"e.g. count >= 3 and not done" = "np. count >= 3 and not done" +"elbowed and curved connectors without a saved path import as straight lines" = "złamane i zakrzywione łączniki bez zapisanej ścieżki są importowane jako linie proste" +"entries without a node type were skipped" = "wpisy bez typu węzła zostały pominięte" +"figcraft:// links from a browser, chat or email open this app and join the session." = "linki figcraft:// z przeglądarki, czatu lub poczty e-mail otwierają tę aplikację i dołączają do sesji." +"filled areas of a vector with their own fill use the layer's fill" = "wypełnione obszary wektora z własnym wypełnieniem używają wypełnienia warstwy" +"from {server}" = "z serwera {server}" +"gamepad triggers without exactly one known controller button were skipped" = "wyzwalacze kontrolera bez dokładnie jednego znanego przycisku zostały pominięte" +"grid track list exceeds 1000 entries; the tracks use the default equal sizing" = "lista ścieżek siatki przekracza 1000 pozycji; ścieżki używają domyślnego równego rozmiaru" +"grid track sizing is unsupported or malformed; affected tracks use equal sizing" = "rozmiar ścieżek siatki jest nieobsługiwany lub nieprawidłowy; dotknięte ścieżki używają równego rozmiaru" +"grid track sizing list exceeds 1000 entries; the tracks use the default equal sizing" = "lista rozmiarów ścieżek siatki przekracza 1000 pozycji; ścieżki używają domyślnego równego rozmiaru" +"hidden" = "ukryty" +"iOS" = "iOS" +"image filters are ambiguous because both filter containers are present; filter conversion was skipped" = "filtry obrazu są niejednoznaczne, ponieważ obecne są oba kontenery filtrów; konwersja filtrów została pominięta" +"image filters use FigCraft's approximate renderer" = "filtry obrazu używają przybliżonego renderera FigCraft" +"instance swap properties whose default component isn't in the file were skipped" = "właściwości podmiany instancji, których domyślny komponent nie znajduje się w pliku, zostały pominięte" +"instances inside their own main component were imported as frames" = "instancje wewnątrz własnego głównego komponentu zostały zaimportowane jako ramki" +"instances of components that aren't in the file show no layers" = "instancje komponentów, których nie ma w pliku, nie pokazują żadnych warstw" +"instances of components that aren't in the file were imported as frames" = "instancje komponentów, których nie ma w pliku, zostały zaimportowane jako ramki" +"instances that would nest a component inside itself were imported as frames" = "instancje zagnieżdżające komponent wewnątrz samego siebie zostały zaimportowane jako ramki" +"instances without a main component were imported as empty frames" = "instancje bez głównego komponentu zostały zaimportowane jako puste ramki" +"invalid internal .fig text path data was ignored" = "nieprawidłowe wewnętrzne dane ścieżki tekstu .fig zostały zignorowane" +"invalid or oversized vector networks import as outlines" = "nieprawidłowe lub przewymiarowane sieci wektorowe są importowane jako kontury" +"invalid or oversized vector path data was skipped" = "nieprawidłowe lub przewymiarowane dane ścieżki wektorowej zostały pominięte" +"just now" = "przed chwilą" +"key triggers without a supported key were skipped" = "wyzwalacze klawiszy z nieobsługiwanym klawiszem zostały pominięte" +"key up triggers import as key presses" = "wyzwalacze zwolnienia klawisza są importowane jako naciśnięcia klawiszy" +"layers without a position were placed at their parent's origin" = "warstwy bez określonej pozycji zostały umieszczone w punkcie początkowym rodzica" +"layers without a type were skipped" = "warstwy bez typu zostały pominięte" +"legacy image filter container is unsupported; filter conversion was skipped" = "przestarzały kontener filtrów obrazu jest nieobsługiwany; konwersja filtrów została pominięta" +"locked" = "Zablokowane" +"malformed image filters container was skipped" = "nieprawidłowy kontener filtrów obrazu został pominięty" +"malformed image paintFilter container was skipped" = "nieprawidłowy kontener filtrów malowania (paintFilter) został pominięty" +"malformed slot content assignment was skipped" = "nieprawidłowe przypisanie zawartości gniazda zostało pominięte" +"no bleed" = "brak spadu" +"nobody else here yet" = "nikogo innego tu jeszcze nie ma" +"non-default slot settings are not represented and were skipped" = "niedomyślne ustawienia gniazd nie są reprezentowane i zostały pominięte" +"now" = "teraz" +"only in a text style or variable" = "tylko w stylu tekstu lub zmiennej" +"or" = "lub" +"point stroke joins were preserved; dashed or variable-width strokes render with the layer join" = "połączenia punktów obrysu zostały zachowane; obrysy przerywane lub o zmiennej szerokości renderują się z łączeniem warstwy" +"preferred slot component keys cannot be resolved to file nodes and were skipped" = "klucze preferowanych komponentów gniazda nie mogą zostać dopasowane do węzłów pliku i zostały pominięte" +"prototype actions of unknown types were skipped" = "akcje prototypu o nieznanych typach zostały pominięte" +"prototype actions without a destination in the file were skipped" = "akcje prototypu bez miejsca docelowego w pliku zostały pominięte" +"prototype expressions on variables that aren't in the variables JSON were skipped" = "wyrażenia prototypu operujące na zmiennych, których nie ma w pliku JSON ze zmiennymi, zostały pominięte" +"prototype expressions with unsupported functions were skipped" = "wyrażenia prototypu z nieobsługiwanymi funkcjami zostały pominięte" +"prototype interactions without supported actions were skipped" = "interakcje prototypu bez obsługiwanych akcji zostały pominięte" +"prototype navigations of unknown types were skipped" = "nawigacje prototypu o nieznanych typach zostały pominięte" +"prototype transitions of unknown types play instantly" = "przejścia prototypu o nieznanych typach odtwarzają się natychmiast" +"prototype triggers of unknown types were skipped" = "wyzwalacze prototypu o nieznanych typach zostały pominięte" +"put a widget on the canvas" = "umieść widżet na płótnie" +"read and edit the document" = "odczytuj i edytuj dokument" +"read the document" = "odczytuj dokument" +"reads the document" = "odczytuje dokument" +"set-variable actions on variables that aren't in the variables JSON were skipped" = "akcje set-variable operujące na zmiennych, których nie ma w pliku JSON ze zmiennymi, zostały pominięte" +"set-variable actions with values that can't be evaluated were skipped" = "akcje set-variable z wartościami, których nie można obliczyć, zostały pominięte" +"set-variable actions with values that don't fit the variable were skipped" = "akcje set-variable z wartościami niepasującymi do zmiennej zostały pominięte" +"set-variable actions without a value were skipped" = "akcje set-variable bez wartości zostały pominięte" +"set-variable-mode actions on collections or modes that aren't in the variables JSON were skipped" = "akcje set-variable-mode dla kolekcji lub trybów, których nie ma w pliku JSON ze zmiennymi, zostały pominięte" +"slot content assignment target is missing or is not slot content" = "cel przypisania zawartości gniazda nie istnieje lub nie jest zawartością gniazda" +"slot content assignment uses the empty GUID sentinel and was skipped" = "przypisanie zawartości gniazda używa pustego identyfikatora GUID i zostało pominięte" +"slot layers without a matching slot property were imported as frames" = "warstwy gniazd bez pasującej właściwości gniazda zostały zaimportowane jako ramki" +"some vector points refer to point settings missing from the file; they use the layer's joins and plain corners" = "niektóre punkty wektorowe odwołują się do ustawień punktów brakujących w pliku; używają połączeń warstwy i zwykłych narożników" +"some vector segments refer to style settings missing from the file; they use the layer's stroke" = "niektóre segmenty wektorowe odwołują się do ustawień stylu brakujących w pliku; używają obrysu warstwy" +"stale or invalid grid track sizing entries were ignored" = "nieaktualne lub nieprawidłowe wpisy rozmiarów ścieżek siatki zostały zignorowane" +"stars and polygons without point counts or geometry use the default point count" = "gwiazdy i wielokąty bez określonej liczby wierzchołków lub geometrii używają domyślnej liczby punktów" +"style settings of some vector segments aren't supported; those segments use the layer's stroke" = "ustawienia stylu niektórych segmentów wektorowych nie są obsługiwane; segmenty te używają obrysu warstwy" +"text path underline or strikethrough decoration is not rendered" = "podkreślenie lub przekreślenie tekstu na ścieżce nie jest renderowane" +"text style overrides refer to missing table entries" = "nadpisania stylu tekstu odwołują się do brakujących wpisów w tabeli" +"the file has no pages" = "plik nie zawiera stron" +"the plugin" = "wtyczka" +"the variables JSON has no `variableCollections` (expected the local variables response) and was ignored" = "plik JSON ze zmiennymi nie zawiera `variableCollections` (oczekiwano odpowiedzi ze zmiennymi lokalnymi) i został zignorowany" +"this session only" = "tylko ta sesja" +"unknown component property types were skipped" = "nieznane typy właściwości komponentu zostały pominięte" +"variable aliases to variables that aren't in the variables JSON (or of another type) were skipped" = "aliasy zmiennych wskazujące na zmienne nieobecne w pliku JSON ze zmiennymi (lub innego typu) zostały pominięte" +"variable bindings to properties the layer doesn't have were skipped" = "powiązania zmiennych z właściwościami, których warstwa nie posiada, zostały pominięte" +"variable bindings to variables that aren't in the variables JSON (library variables) were skipped" = "powiązania zmiennych ze zmiennymi nieobecnymi w pliku JSON ze zmiennymi (zmienne biblioteki) zostały pominięte" +"variable bindings were skipped: the file JSON has no variable definitions (pass the local variables JSON as `variables`, or embed its `meta` next to the file JSON)" = "powiązania zmiennych zostały pominięte: plik JSON nie zawiera definicji zmiennych (przekaż JSON zmiennych lokalnych jako `variables` lub osadź jego `meta` obok pliku JSON)" +"variable bindings whose variable type doesn't fit the property were skipped" = "powiązania zmiennych, których typ nie pasuje do właściwości, zostały pominięte" +"variable collections without modes were skipped" = "kolekcje zmiennych bez trybów zostały pominięte" +"variable mode lookups in prototype expressions that aren't a variable and one of its modes were skipped" = "odwołania do trybu zmiennej w wyrażeniach prototypu niebędące zmienną i jednym z jej trybów zostały pominięte" +"variable modes of collections that aren't in the file were skipped" = "tryby zmiennych dla kolekcji nieobecnych w pliku zostały pominięte" +"variable modes of collections that aren't in the variables JSON were skipped" = "tryby zmiennych dla kolekcji nieobecnych w pliku JSON ze zmiennymi zostały pominięte" +"variable values that don't match their type (or composed colours) were skipped" = "wartości zmiennych niezgodne ze swoim typem (lub złożone kolory) zostały pominięte" +"variables of unknown types were skipped" = "zmienne o nieznanych typach zostały pominięte" +"variables whose collection isn't in the variables JSON were skipped" = "zmienne, których kolekcji nie ma w pliku JSON ze zmiennymi, zostały pominięte" +"variables without readable values were skipped" = "zmienne bez czytelnych wartości zostały pominięte" +"vector points with unknown stroke joins use the layer join" = "punkty wektorowe z nieznanymi połączeniami obrysu używają połączenia warstwy" +"vectors without geometry are empty" = "wektory bez geometrii są puste" +"vectors without geometry are empty (export the file JSON with geometry=paths)" = "wektory bez geometrii są puste (wyeksportuj plik JSON z opcją geometry=paths)" +"very long text was cut to 1,048,576 characters" = "bardzo długi tekst został przycięty do 1 048 576 znaków" +"very long vector paths were skipped" = "bardzo długie ścieżki wektorowe zostały pominięte" +"video actions are not supported and were skipped" = "akcje wideo nie są obsługiwane i zostały pominięte" +"video triggers are not supported and were skipped" = "wyzwalacze wideo nie są obsługiwane i zostały pominięte" +"yesterday" = "wczoraj" +"{instances} in {files}" = "{instances} w {files}" +"{language}: draft translation, waiting for a native speaker's review." = "{language}: wersja robocza tłumaczenia, oczekuje na weryfikację przez native speakera." +"{length} bleed" = "spad {length}" +"{month} {day}, {year}" = "{day} {month} {year}" +"{name} cleared from the list" = "Usunięto {name} z listy" +"{name} is forgotten on this computer; the file stays on {server}" = "{name} usunięto z pamięci tego komputera; plik pozostaje na {server}" +"{name} is on the server." = "{name} znajduje się na serwerze." +"{name} is spotlighting" = "{name} prezentuje widok (spotlight)" +"{name} is too large to open in the browser (over 512 MB)." = "Plik {name} jest za duży, aby otworzyć go w przeglądarce (ponad 512 MB)." +"{name}\nClick to apply to the selection" = "{name}\nKliknij, aby zastosować do zaznaczenia" +"{name}\nDrag onto the canvas, or click to place in the middle of the view.\nHold Alt (Option) while dropping onto an instance to swap it." = "{name}\nPrzeciągnij na płótno lub kliknij, aby umieścić na środku widoku.\nPrzytrzymaj klawisz Alt (Option) podczas upuszczania na instancję, aby ją zamienić." +"{name} (from {library})" = "{name} (z biblioteki {library})" +"{name} (you)" = "{name} (Ty)" +"{name} ({layers} layers)" = "{name} ({layers} warstw)" +"{name} ({tag}): {min} – {max}, default {default}. Double-click the slider to go back to the style's value; Escape while dragging cancels." = "{name} ({tag}): {min} – {max}, domyślnie {default}. Kliknij dwukrotnie suwak, aby przywrócić wartość stylu; naciśnięcie Escape podczas przeciągania anuluje." +"{name} from {path}" = "{name} z {path}" +"{name} was removed from the server" = "Plik {name} został usunięty z serwera" +"{name} · deleted on main" = "{name} · usunięto w gałęzi głównej" +"{name} · deleted on the branch" = "{name} · usunięto w gałęzi" +"{name} · not published" = "{name} · nieopublikowane" +"{name} · on a server" = "{name} · na serwerze" +"{name} · published version {v}" = "{name} · opublikowana wersja {v}" +"{name} · version {v} on the server" = "{name} · wersja {v} na serwerze" +"{name}, in a live session" = "{name}, w sesji na żywo" +"{name}, online, open only to you" = "{name}, online, otwarty tylko dla Ciebie" +"{name}, {kind}, {w} by {h}, selected" = "{name}, {kind}, {w} × {h}, zaznaczono" +"{n} added" = { one = "Dodano {n}", few = "Dodano {n}", many = "Dodano {n}", other = "Dodano {n}" } +"{n} autosave versions" = { one = "{n} wersja autozapisu", few = "{n} wersje autozapisu", many = "{n} wersji autozapisu", other = "{n} wersji autozapisu" } +"{n} changed" = { one = "Zmieniono {n}", few = "Zmieniono {n}", many = "Zmieniono {n}", other = "Zmieniono {n}" } +"{n} columns" = { one = "{n} kolumna", few = "{n} kolumny", many = "{n} kolumn", other = "{n} kolumn" } +"{n} components" = { one = "{n} komponent", few = "{n} komponenty", many = "{n} komponentów", other = "{n} komponentów" } +"{n} components and styles have newer versions" = { one = "{n} komponent lub styl ma nowszą wersję", few = "{n} komponenty i style mają nowsze wersje", many = "{n} komponentów i stylów ma nowsze wersje", other = "{n} komponentów i stylów ma nowsze wersje" } +"{n} conflicts to resolve" = { one = "{n} konflikt do rozwiązania", few = "{n} konflikty do rozwiązania", many = "{n} konfliktów do rozwiązania", other = "{n} konfliktów do rozwiązania" } +"{n} days ago" = { one = "{n} dzień temu", few = "{n} dni temu", many = "{n} dni temu", other = "{n} dni temu" } +"{n} files" = { one = "{n} plik", few = "{n} pliki", many = "{n} plików", other = "{n} plików" } +"{n} hours ago" = { one = "{n} godzinę temu", few = "{n} godziny temu", many = "{n} godzin temu", other = "{n} godzin temu" } +"{n} instances" = { one = "{n} instancja", few = "{n} instancje", many = "{n} instancji", other = "{n} instancji" } +"{n} layers" = { one = "{n} warstwa", few = "{n} warstwy", many = "{n} warstw", other = "{n} warstw" } +"{n} layers as one file" = { one = "{n} warstwa jako jeden plik", few = "{n} warstwy jako jeden plik", many = "{n} warstw jako jeden plik", other = "{n} warstw jako jeden plik" } +"{n} layers selected" = { one = "Zaznaczono {n} warstwę", few = "Zaznaczono {n} warstwy", many = "Zaznaczono {n} warstw", other = "Zaznaczono {n} warstw" } +"{n} layers · {parts}" = { one = "{n} warstwa · {parts}", few = "{n} warstwy · {parts}", many = "{n} warstw · {parts}", other = "{n} warstw · {parts}" } +"{n} matches" = { one = "{n} dopasowanie", few = "{n} dopasowania", many = "{n} dopasowań", other = "{n} dopasowań" } +"{n} minutes ago" = { one = "{n} minutę temu", few = "{n} minuty temu", many = "{n} minut temu", other = "{n} minut temu" } +"{n} months ago" = { one = "{n} miesiąc temu", few = "{n} miesiące temu", many = "{n} miesięcy temu", other = "{n} miesięcy temu" } +"{n} more notes omitted from this report" = { one = "Pominięto jeszcze {n} notatkę w tym raporcie", few = "Pominięto jeszcze {n} notatki w tym raporcie", many = "Pominięto jeszcze {n} notatek w tym raporcie", other = "Pominięto jeszcze {n} notatek w tym raporcie" } +"{n} of {total}" = "{n} z {total}" +"{n} people joined" = { one = "Dołączyła {n} osoba", few = "Dołączyły {n} osoby", many = "Dołączyło {n} osób", other = "Dołączyło {n} osób" } +"{n} preferred components" = { one = "{n} preferowany komponent", few = "{n} preferowane komponenty", many = "{n} preferowanych komponentów", other = "{n} preferowanych komponentów" } +"{n} ready" = { one = "{n} gotowy", few = "{n} gotowe", many = "{n} gotowych", other = "{n} gotowych" } +"{n} removed" = { one = "Usunięto {n}", few = "Usunięto {n}", many = "Usunięto {n}", other = "Usunięto {n}" } +"{n} replies" = { one = "{n} odpowiedź", few = "{n} odpowiedzi", many = "{n} odpowiedzi", other = "{n} odpowiedzi" } +"{n} rows" = { one = "{n} wiersz", few = "{n} wiersze", many = "{n} wierszy", other = "{n} wierszy" } +"{n} styles" = { one = "{n} styl", few = "{n} style", many = "{n} stylów", other = "{n} stylów" } +"{n} text layers" = { one = "{n} warstwa tekstowa", few = "{n} warstwy tekstowe", many = "{n} warstw tekstowych", other = "{n} warstw tekstowych" } +"{n} variables" = { one = "{n} zmienna", few = "{n} zmienne", many = "{n} zmiennych", other = "{n} zmiennych" } +"{n} weeks ago" = { one = "{n} tydzień temu", few = "{n} tygodnie temu", many = "{n} tygodni temu", other = "{n} tygodni temu" } +"{n} years ago" = { one = "{n} rok temu", few = "{n} lata temu", many = "{n} lat temu", other = "{n} lat temu" } +"{n}d" = "{n} d" +"{n}h" = "{n} godz." +"{n}m" = "{n} min" +"{n}w" = "{n} tyg." +"{page}, nothing selected" = "{page}, nic nie zaznaczono" +"{profile} (current)" = "{profile} (bieżący)" +"{property} is its value; {property ? \"a\" : \"b\"} picks a string. Other {…} stays as written." = "{property} to jej wartość; {property ? \"a\" : \"b\"} wybiera tekst. Pozostałe wyrażenia {…} pozostają bez zmian." +"{server} can't say what became of your files (it is older than this app)." = "{server} nie może określić stanu Twoich plików (jest starszy niż ta aplikacja)." +"{server} · No longer on the server" = "{server} · Brak na serwerze" +"{server} · {shared} · Opened {date}" = "{server} · {shared} · Otwarto {date}" +"{tool} hold {key}" = "{tool} przytrzymaj {key}" +"{value} isn't a colour" = "Wartość {value} nie jest kolorem" +"{when} · edited" = "{when} · edytowano" +"← ↑ → ↓" = "← ↑ → ↓" +"← → ↑ ↓" = "← → ↑ ↓" +"←" = "←" +"→, Space or N" = "→, Spacja lub N" +"↵" = "↵" +"⇥ / ⇧⇥" = "⇥ / ⇧⇥" +"⌘ arrows" = "⌘ strzałki" +"⌘ click" = "⌘ kliknięcie" +"⌘ scroll" = "⌘ przewijanie" +"⌥ drag" = "⌥ przeciągnięcie" + +[commands] +"align.bottom" = "Wyrównaj do dołu" +"align.hcenter" = "Wyrównaj poziomo do środka" +"align.left" = "Wyrównaj do lewej" +"align.right" = "Wyrównaj do prawej" +"align.top" = "Wyrównaj do góry" +"align.vcenter" = "Wyrównaj pionowo do środka" +"annotation.add" = "Dodaj adnotację" +"annotation.addCategory" = "Dodaj kategorię adnotacji" +"annotation.categories" = "Kategorie adnotacji" +"annotation.edit" = "Edytuj adnotację" +"annotation.editCategory" = "Edytuj kategorię adnotacji" +"annotation.list" = "Lista adnotacji" +"annotation.remove" = "Usuń adnotację" +"annotation.removeCategory" = "Usuń kategorię adnotacji" +"app.actions" = "Akcje" +"app.preferences" = "Preferencje" +"arrange.back" = "Przesuń na sam spód" +"arrange.backward" = "Przesuń do tyłu" +"arrange.forward" = "Przesuń do przodu" +"arrange.front" = "Przesuń na sam wierzch" +"arrange.packH" = "Upakuj w poziomie" +"arrange.packV" = "Upakuj w pionie" +"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" +"bool.subtract" = "Odejmij zaznaczenie" +"bool.union" = "Połącz zaznaczenie" +"branch.archive" = "Zarchiwizuj gałąź" +"branch.create" = "Utwórz gałąź" +"branch.list" = "Lista gałęzi" +"branch.merge" = "Scal gałąź" +"branch.open" = "Otwórz gałąź" +"branch.review" = "Przejrzyj gałąź" +"branch.updateFromMain" = "Zaktualizuj z gałęzi głównej" +"codeConnect.export" = "Eksportuj połączenia kodu" +"codeConnect.import" = "Importuj połączenia kodu" +"codeConnect.remove" = "Odłącz od kodu" +"codeConnect.set" = "Połącz z kodem" +"collab.cursor" = "Pokaż kursor na pozycji" +"collab.cursorChat" = "Czat kursora" +"collab.diff" = "Pokaż zmiany synchronizacji" +"collab.files" = "Twoje pliki online" +"collab.follow" = "Obserwuj" +"collab.forgetFile" = "Zapomnij plik online" +"collab.host" = "Rozpocznij sesję na żywo" +"collab.join" = "Dołącz do sesji na żywo" +"collab.leave" = "Opuść sesję na żywo" +"collab.manageFile" = "Zarządzaj opublikowanym plikiem" +"collab.openFile" = "Otwórz plik online" +"collab.peers" = "Osoby w tym pliku" +"collab.publish" = "Opublikuj na serwerze" +"collab.recoverFile" = "Odzyskaj plik online" +"collab.registerLinks" = "Otwieraj linki z zaproszeniem w FigCraft" +"collab.removePerson" = "Usuń z sesji" +"collab.report" = "Zgłoś plik operatorowi serwera" +"collab.serverInfo" = "Informacje o serwerze" +"collab.share" = "Udostępnij z kodem dołączenia" +"collab.spotlight" = "Wyróżnij mój widok" +"collab.stop" = "Zatrzymaj sesję na żywo" +"command.batch" = "Wykonaj serię poleceń" +"comment.add" = "Dodaj komentarz" +"comment.delete" = "Usuń komentarz" +"comment.edit" = "Edytuj komentarz" +"comment.link" = "Kopiuj link do komentarza" +"comment.list" = "Lista komentarzy" +"comment.markRead" = "Oznacz komentarz jako przeczytany" +"comment.move" = "Przenieś komentarz" +"comment.next" = "Następny komentarz" +"comment.open" = "Otwórz komentarz" +"comment.people" = "Osoby w komentarzach" +"comment.react" = "Zareaguj na komentarz" +"comment.reply" = "Odpowiedz na komentarz" +"comment.resolve" = "Rozwiąż komentarz" +"comment.zoom" = "Powiększ do komentarzy" +"component.addProperty" = "Dodaj właściwość komponentu" +"component.addVariant" = "Dodaj wariant" +"component.bindProperty" = "Powiąż warstwę z właściwością" +"component.combineVariants" = "Połącz jako warianty" +"component.create" = "Utwórz komponent" +"component.createMultiple" = "Utwórz wiele komponentów" +"component.deleteProperty" = "Usuń właściwość komponentu" +"component.detach" = "Odłącz instancję" +"component.editProperty" = "Edytuj właściwość komponentu" +"component.exposeInstance" = "Udostępnij zagnieżdżoną instancję" +"component.goToMain" = "Przejdź do głównego komponentu" +"component.hideWhenPublishing" = "Ukryj podczas publikowania" +"component.list" = "Lista komponentów" +"component.makeSlot" = "Utwórz gniazdo (Slot)" +"component.playground" = "Środowisko testowe komponentu (Playground)" +"component.properties" = "Właściwości komponentu" +"component.pushChanges" = "Zastosuj zmiany w głównym komponencie" +"component.removeSlot" = "Usuń gniazdo" +"component.resetInstance" = "Zresetuj wszystkie zmiany" +"component.resetOverride" = "Zresetuj zmianę" +"component.restoreMain" = "Przywróć główny komponent" +"dev.compare" = "Porównaj zmiany" +"dev.focus" = "Widok skupienia" +"dev.resolveChange" = "Rozwiąż zmianę" +"dev.unfocus" = "Wyjdź z widoku skupienia" +"devResource.add" = "Dodaj zasób programistyczny" +"devResource.edit" = "Edytuj zasób programistyczny" +"devResource.list" = "Lista zasobów programistycznych" +"devResource.remove" = "Usuń zasób programistyczny" +"distribute.horizontal" = "Rozmieść równe odstępy poziome" +"distribute.vertical" = "Rozmieść równe odstępy pionowe" +"doc.home" = "Ekran główny" +"doc.list" = "Lista dokumentów" +"doc.nextTab" = "Następna karta" +"doc.previousTab" = "Poprzednia karta" +"doc.switch" = "Przełącz dokument" +"edit.copy" = "Kopiuj" +"edit.copyAsCss" = "Kopiuj jako CSS" +"edit.copyAsPng" = "Kopiuj jako PNG" +"edit.copyAsSvg" = "Kopiuj jako SVG" +"edit.copyAsText" = "Kopiuj jako tekst" +"edit.copyLink" = "Kopiuj łącze do zaznaczenia" +"edit.copyProperties" = "Kopiuj właściwości" +"edit.cut" = "Wytnij" +"edit.delete" = "Usuń" +"edit.duplicate" = "Powiel" +"edit.findReplace" = "Znajdź i zamień…" +"edit.paste" = "Wklej" +"edit.pasteDesignLayers" = "Wklej warstwy projektu" +"edit.pasteOver" = "Wklej na zaznaczenie" +"edit.pasteProperties" = "Wklej właściwości" +"edit.pasteReplace" = "Wklej, aby zastąpić" +"edit.pickColor" = "Wybierz kolor" +"edit.redo" = "Ponów" +"edit.repeat" = "Powtórz ostatnie polecenie" +"edit.setDefaultProperties" = "Ustaw właściwości domyślne" +"edit.undo" = "Cofnij" +"effect.add" = "Dodaj efekt" +"effect.move" = "Przenieś efekt" +"effect.remove" = "Usuń efekt" +"effect.set" = "Ustaw efekt" +"export.add" = "Dodaj ustawienie eksportu" +"export.remove" = "Usuń ustawienie eksportu" +"export.update" = "Edytuj ustawienie eksportu" +"file.close" = "Zamknij kartę" +"file.colorProfile" = "Profil kolorów" +"file.duplicate" = "Powiel plik" +"file.export" = "Eksportuj…" +"file.exportFramesPdf" = "Eksportuj ramki do PDF" +"file.exportLayers" = "Eksportuj warstwy" +"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" +"file.placeImage" = "Umieść obraz" +"file.recover" = "Odzyskaj plik" +"file.rename" = "Zmień nazwę pliku" +"file.revert" = "Przywróć do zapisanego" +"file.save" = "Zapisz" +"file.saveAs" = "Zapisz jako…" +"file.saveVersion" = "Zapisz w historii wersji…" +"file.versionHistory" = "Pokaż historię wersji" +"fill.add" = "Dodaj wypełnienie" +"fill.move" = "Przenieś wypełnienie" +"fill.pattern" = "Wypełnienie wzorem" +"fill.remove" = "Usuń wypełnienie" +"fill.set" = "Ustaw wypełnienie" +"frame.create" = "Utwórz ramkę" +"frame.presets" = "Lista presetów ramek" +"frame.swapOrientation" = "Zmień orientację ramki" +"grid.add" = "Dodaj siatkę układu" +"grid.move" = "Przenieś siatkę układu" +"grid.remove" = "Usuń siatkę układu" +"guide.add" = "Dodaj prowadnicę" +"guide.clear" = "Wyczyść prowadnice" +"guide.move" = "Przesuń prowadnicę" +"guide.remove" = "Usuń prowadnicę" +"guide.select" = "Zaznacz prowadnicę" +"help.about" = "O programie FigCraft" +"help.gettingStarted" = "Wprowadzenie" +"help.page" = "Strona pomocy" +"help.shortcuts" = "Skróty klawiszowe" +"image.adjust" = "Dostosuj obraz" +"image.deletePreset" = "Usuń preset obrazu" +"image.place" = "Umieść obraz" +"image.presets" = "Presety obrazów" +"image.savePreset" = "Zapisz preset obrazu" +"inspect.assets" = "Zasoby do inspekcji" +"inspect.code" = "Kod warstwy" +"inspect.componentCode" = "Kod komponentu" +"inspect.properties" = "Właściwości do inspekcji" +"inspect.readyForDev" = "Gotowe do wdrożenia" +"instance.create" = "Utwórz instancję" +"instance.exposed" = "Udostępnione zagnieżdżone instancje" +"instance.place" = "Umieść instancję" +"instance.resetSlot" = "Zresetuj gniazdo" +"instance.setProperties" = "Ustaw właściwości instancji" +"instance.slotInsert" = "Wstaw do gniazda" +"instance.swap" = "Podmień instancję" +"instance.swapAt" = "Podmień instancję w punkcie" +"layers.collapseAll" = "Zwiń wszystkie warstwy" +"layout.add" = "Dodaj automatyczny układ" +"layout.remove" = "Usuń automatyczny układ" +"layout.setTrack" = "Ustaw rozmiar linii siatki" +"layout.suggest" = "Sugeruj automatyczny układ" +"libraries.open" = "Biblioteki" +"libraries.publish" = "Publikuj bibliotekę…" +"library.acceptUpdates" = "Zaakceptuj aktualizacje biblioteki" +"library.add" = "Dodaj bibliotekę" +"library.aliasVariable" = "Utwórz alias zmiennej biblioteki" +"library.applyStyle" = "Zastosuj styl biblioteki" +"library.bindVariable" = "Powiąż zmienną biblioteki" +"library.extendCollection" = "Rozszerz kolekcję biblioteki" +"library.import" = "Importuj komponent biblioteki" +"library.importVariable" = "Importuj zmienną biblioteki" +"library.list" = "Lista bibliotek" +"library.place" = "Umieść komponent biblioteki" +"library.publish" = "Opublikuj bibliotekę" +"library.publishToServer" = "Opublikuj bibliotekę na serwerze" +"library.remove" = "Usuń bibliotekę" +"library.setVariableMode" = "Ustaw tryb zmiennej biblioteki" +"library.swap" = "Podmień bibliotekę" +"library.swapAt" = "Podmień instancję na komponent biblioteki" +"library.updates" = "Aktualizacje biblioteki" +"library.usage" = "Użycie biblioteki" +"measure.distances" = "Zmierz odległości" +"measure.padding" = "Zmierz marginesy automatycznego układu" +"node.create" = "Utwórz warstwę" +"node.get" = "Pobierz warstwę" +"node.reorder" = "Zmień kolejność warstwy" +"node.reparent" = "Przenieś warstwę do" +"node.set" = "Ustaw właściwości" +"object.batchRename" = "Zmień nazwy wsadowo…" +"object.convertToFrame" = "Konwertuj na ramkę" +"object.convertToGroup" = "Konwertuj na grupę" +"object.convertToSection" = "Konwertuj na sekcję" +"object.flipH" = "Odbij w poziomie" +"object.flipV" = "Odbij w pionie" +"object.frameSelection" = "Umieść zaznaczenie w ramce" +"object.group" = "Grupuj zaznaczenie" +"object.hideOthers" = "Ukryj pozostałe warstwy" +"object.mask" = "Użyj jako maski" +"object.moveToPage" = "Przenieś na stronę" +"object.opacityKeys" = "Krycie z klawiatury numerycznej" +"object.rasterize" = "Rasteryzuj zaznaczenie" +"object.releaseMask" = "Zwolnij maskę" +"object.removeFill" = "Usuń wypełnienie" +"object.removeFrame" = "Usuń ramkę" +"object.removeStroke" = "Usuń obrys" +"object.rename" = "Zmień nazwę" +"object.resizeToFit" = "Dopasuj rozmiar do zawartości" +"object.rotate180" = "Obróć o 180°" +"object.rotateLeft" = "Obróć o 90° w lewo" +"object.rotateRight" = "Obróć o 90° w prawo" +"object.setThumbnail" = "Ustaw jako miniaturę" +"object.swapFillStroke" = "Zamień wypełnienie i obrys" +"object.toggleLock" = "Zablokuj/Odblokuj zaznaczenie" +"object.toggleVisible" = "Pokaż/Ukryj zaznaczenie" +"object.ungroup" = "Rozgrupuj zaznaczenie" +"object.wrapInSection" = "Umieść w nowej sekcji" +"page.add" = "Dodaj stronę" +"page.delete" = "Usuń stronę" +"page.duplicate" = "Powiel stronę" +"page.list" = "Lista stron" +"page.move" = "Przenieś stronę" +"page.rename" = "Zmień nazwę strony" +"page.select" = "Przejdź do strony" +"page.setBackground" = "Tło strony" +"paint.documentColors" = "Kolory dokumentu" +"plugin.close" = "Zamknij panel wtyczki" +"plugin.directory.install" = "Zainstaluj wtyczkę z katalogu" +"plugin.directory.list" = "Przeglądaj katalog wtyczek" +"plugin.directory.publish" = "Opublikuj wtyczkę w katalogu" +"plugin.directory.unpublish" = "Wycofaj wtyczkę z katalogu" +"plugin.event" = "Użyj kontrolki panelu wtyczki" +"plugin.import" = "Importuj wtyczkę z manifestu…" +"plugin.list" = "Lista wtyczek" +"plugin.remove" = "Usuń wtyczkę" +"plugin.run" = "Uruchom wtyczkę" +"plugin.ui" = "Panel wtyczki" +"plugins.manage" = "Zarządzaj wtyczkami…" +"plugins.runLast" = "Uruchom ponownie ostatnią wtyczkę" +"pref.autoSave" = "Zapisuj pliki automatycznie" +"pref.highlightHover" = "Podświetlaj warstwy przy najechaniu myszą" +"pref.keepTool" = "Zachowaj wybrane narzędzie po użyciu" +"pref.language" = "Język interfejsu" +"pref.nudge" = "Wartości przesunięcia (Nudge)" +"pref.numberKeysOpacity" = "Klawisze numeryczne zmieniają krycie" +"pref.publishServer" = "Serwer do udostępniania online" +"pref.reduceMotion" = "Ogranicz animacje" +"pref.renameDuplicates" = "Zmieniaj nazwy duplikatów" +"pref.showDimensions" = "Pokazuj wymiary przy zaznaczeniu" +"pref.userName" = "Twoja nazwa w komentarzach" +"proto.action.add" = "Dodaj akcję prototypu" +"proto.action.move" = "Zmień kolejność akcji prototypu" +"proto.action.remove" = "Usuń akcję prototypu" +"proto.action.set" = "Edytuj akcję prototypu" +"proto.closePreview" = "Zamknij podgląd prototypu" +"proto.conditional.addBlock" = "Dodaj else if / else" +"proto.conditional.removeBlock" = "Usuń if / else if / else" +"proto.conditional.setCondition" = "Edytuj warunek" +"proto.evaluate" = "Oblicz wyrażenie prototypu" +"proto.flow.add" = "Dodaj punkt początkowy przepływu" +"proto.flow.remove" = "Usuń przepływ" +"proto.flow.rename" = "Zmień nazwę przepływu" +"proto.interaction.add" = "Dodaj interakcję" +"proto.interaction.clear" = "Usuń wszystkie interakcje" +"proto.interaction.remove" = "Usuń interakcję" +"proto.interaction.set" = "Edytuj interakcję" +"proto.present" = "Prezentuj" +"proto.presentOptions" = "Opcje prezentacji" +"proto.preview" = "Otwórz podgląd prototypu" +"proto.settings" = "Ustawienia prototypu" +"section.toggleContents" = "Ukryj/Pokaż zawartość sekcji" +"select.add" = "Dodaj do zaznaczenia" +"select.all" = "Zaznacz wszystko" +"select.area" = "Zaznacz obszar" +"select.byName" = "Zaznacz warstwy po nazwie" +"select.children" = "Zaznacz elementy podrzędne" +"select.inverse" = "Odwróć zaznaczenie" +"select.layersAt" = "Warstwy w punkcie" +"select.matching" = "Zaznacz pasujące warstwy" +"select.next" = "Zaznacz następną warstwę" +"select.none" = "Odznacz wszystko" +"select.parent" = "Zaznacz element nadrzędny" +"select.previous" = "Zaznacz poprzednią warstwę" +"select.remove" = "Usuń z zaznaczenia" +"select.sameEffect" = "Zaznacz elementy z takim samym efektem" +"select.sameFill" = "Zaznacz elementy z takim samym wypełnieniem" +"select.sameFont" = "Zaznacz elementy z taką samą czcionką" +"select.sameInstance" = "Zaznacz taką samą instancję" +"select.sameProperties" = "Zaznacz elementy z takimi samymi właściwościami" +"select.sameStroke" = "Zaznacz elementy z takim samym obrysem" +"select.sameText" = "Zaznacz elementy z takimi samymi właściwościami tekstu" +"select.set" = "Zaznacz" +"selection.applyColorStyle" = "Zastosuj styl koloru do zaznaczenia" +"selection.colors" = "Kolory zaznaczenia" +"selection.selectColor" = "Zaznacz warstwy z kolorem" +"selection.setPaint" = "Zmień kolor zaznaczenia" +"shape.arrow" = "Strzałka" +"shape.ellipse" = "Elipsa" +"shape.line" = "Linia" +"shape.polygon" = "Wielokąt" +"shape.rectangle" = "Prostokąt" +"shape.star" = "Gwiazda" +"shapeBuilder.merge" = "Połącz obszary kształtów" +"shapeBuilder.mode" = "Tryb generatora kształtów" +"shapeBuilder.regions" = "Obszary generatora kształtów" +"shapeBuilder.remove" = "Usuń obszary" +"stroke.add" = "Dodaj obrys" +"stroke.brush" = "Pędzel" +"stroke.move" = "Przenieś obrys" +"stroke.remove" = "Usuń obrys" +"stroke.set" = "Ustaw obrys" +"stroke.widthProfile" = "Profil grubości" +"style.apply" = "Zastosuj styl" +"style.bind" = "Powiąż zmienną w stylu" +"style.create" = "Utwórz styl" +"style.delete" = "Usuń styl" +"style.detach" = "Odłącz styl" +"style.duplicate" = "Powiel styl" +"style.list" = "Lista stylów" +"style.move" = "Przenieś style" +"style.renameFolder" = "Zmień nazwę folderu stylów" +"style.update" = "Edytuj styl" +"template.list" = "Lista szablonów" +"text.alignCenter" = "Wyrównaj tekst do środka" +"text.alignJustify" = "Wyjustuj tekst" +"text.alignLeft" = "Wyrównaj tekst do lewej" +"text.alignRight" = "Wyrównaj tekst do prawej" +"text.bold" = "Pogrubienie" +"text.bulletList" = "Lista punktowana" +"text.case" = "Wielkość liter" +"text.caseLower" = "Małe litery" +"text.caseOriginal" = "Oryginalna wielkość liter" +"text.caseSmallCaps" = "Kapitaliki" +"text.caseTitle" = "Jak nazwy własne" +"text.caseUpper" = "Wielkie litery" +"text.create" = "Tekst" +"text.decoration" = "Dekoracja tekstu" +"text.edit.begin" = "Edytuj tekst" +"text.edit.caret" = "Ustaw kursor tekstu" +"text.edit.end" = "Zakończ edycję tekstu" +"text.edit.insert" = "Wpisz tekst" +"text.edit.key" = "Klawisz tekstu" +"text.edit.preedit" = "Redaguj tekst" +"text.edit.selectLine" = "Zaznacz wiersz" +"text.edit.selectWord" = "Zaznacz słowo" +"text.edit.selection" = "Zaznaczenie tekstu" +"text.endPreview" = "Zakończ podgląd tekstu" +"text.fonts" = "Lista czcionek" +"text.italic" = "Kursywa" +"text.lineHeightDown" = "Zmniejsz interlinię" +"text.lineHeightUp" = "Zwiększ interlinię" +"text.link" = "Wstaw łącze" +"text.listIndent" = "Zwiększ wcięcie listy" +"text.listOutdent" = "Zmniejsz wcięcie listy" +"text.listStyle" = "Styl listy" +"text.maxLines" = "Maksymalna liczba wierszy" +"text.missingFonts" = "Brakujące czcionki" +"text.numberList" = "Lista numerowana" +"text.onPath" = "Tekst na ścieżce" +"text.paragraphIndent" = "Wcięcie akapitu" +"text.paragraphSpacing" = "Odstęp między akapitami" +"text.pathOptions" = "Opcje ścieżki tekstu" +"text.preview" = "Podgląd zmiany tekstu" +"text.removeFromPath" = "Usuń tekst ze ścieżki" +"text.set" = "Ustaw tekst" +"text.setFeatures" = "Funkcje OpenType" +"text.setVariations" = "Osie czcionki zmiennej (Variable Font)" +"text.sizeDown" = "Zmniejsz rozmiar czcionki" +"text.sizeUp" = "Zwiększ rozmiar czcionki" +"text.strikethrough" = "Przekreślenie" +"text.style" = "Zastosuj styl do fragmentu tekstu" +"text.trackingDown" = "Zmniejsz odstęp między literami" +"text.trackingUp" = "Zwiększ odstęp między literami" +"text.underline" = "Podkreślenie" +"text.weightDown" = "Zmniejsz grubość czcionki" +"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" +"tool.pencilBrush" = "Pędzel ołówka" +"tool.place" = "Umieść za pomocą narzędzia" +"tool.polygon" = "Wielokąt" +"tool.rectangle" = "Prostokąt" +"tool.scale" = "Skalowanie" +"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" +"transform.nudge" = "Przesuń o krok (Nudge)" +"transform.nudgeSize" = "Zmień rozmiar o krok" +"transform.resize" = "Zmień rozmiar" +"transform.rotate" = "Obróć" +"transform.rotationOrigin" = "Przesuń punkt obrotu" +"transform.scale" = "Skaluj" +"transform.setRotationOrigin" = "Ustaw punkt obrotu" +"variable.addMode" = "Dodaj tryb" +"variable.bind" = "Powiąż zmienną" +"variable.create" = "Utwórz zmienną" +"variable.createCollection" = "Utwórz kolekcję zmiennych" +"variable.delete" = "Usuń zmienną" +"variable.deleteCollection" = "Usuń kolekcję" +"variable.duplicate" = "Powiel zmienną" +"variable.export" = "Eksportuj zmienne" +"variable.import" = "Importuj zmienne" +"variable.list" = "Lista zmiennych" +"variable.moveMode" = "Przenieś tryb" +"variable.removeMode" = "Usuń tryb" +"variable.renameCollection" = "Zmień nazwę kolekcji" +"variable.renameMode" = "Zmień nazwę trybu" +"variable.resetOverride" = "Zresetuj nadpisanie zmiennej" +"variable.set" = "Ustaw wartość zmiennej" +"variable.setMode" = "Ustaw tryb zmiennej" +"variable.update" = "Edytuj zmienną" +"vector.create" = "Wektor" +"vector.deleteHeal" = "Usuń punkt i napraw zaznaczenie" +"vector.edit.addVertex" = "Dodaj punkt" +"vector.edit.begin" = "Edytuj wektor" +"vector.edit.bend" = "Zegnij segment" +"vector.edit.bounds" = "Granice zaznaczonych punktów" +"vector.edit.connect" = "Połącz punkty" +"vector.edit.cornerRadius" = "Promień zaokrąglenia punktu" +"vector.edit.cut" = "Rozetnij ścieżkę" +"vector.edit.delete" = "Usuń punkty" +"vector.edit.end" = "Zakończ edycję" +"vector.edit.erase" = "Wymaż" +"vector.edit.lasso" = "Zaznacz lasso" +"vector.edit.mirroring" = "Symetria uchwytów" +"vector.edit.moveHandles" = "Przesuń uchwyty" +"vector.edit.moveVertices" = "Przesuń punkty" +"vector.edit.nudge" = "Przesuń punkty o krok" +"vector.edit.paint" = "Wiadro z farbą" +"vector.edit.pen" = "Punkt pióra" +"vector.edit.penEnd" = "Zakończ ścieżkę pióra" +"vector.edit.position" = "Pozycja punktu" +"vector.edit.resize" = "Zmień rozmiar punktów" +"vector.edit.rotate" = "Obróć punkty" +"vector.edit.select" = "Zaznacz punkty" +"vector.edit.selectAll" = "Zaznacz wszystkie punkty" +"vector.edit.setHandle" = "Ustaw uchwyt" +"vector.edit.smooth" = "Wygładź lub wyostrz punkty" +"vector.edit.state" = "Stan edycji wektora" +"vector.edit.strokeCap" = "Zakończenie obrysu punktu" +"vector.edit.strokeJoin" = "Połączenie obrysu punktu" +"vector.edit.tool" = "Narzędzie edycji wektora" +"vector.edit.transform" = "Przekształć punkty" +"vector.edit.widthPoint" = "Punkt szerokości" +"vector.flatten" = "Spłaszcz do wektora" +"vector.join" = "Połącz ścieżki" +"vector.offset" = "Przesunięcie ścieżki (Offset)" +"vector.outlineStroke" = "Przekształć obrys w ścieżkę" +"vector.reverse" = "Odwróć kierunek ścieżki" +"vector.simplify" = "Uprość wektor" +"vector.smoothJoin" = "Płynne połączenie zaznaczenia" +"version.autosave" = "Wersja autozapisu" +"version.delete" = "Usuń wersję" +"version.duplicate" = "Powiel wersję" +"version.exitPreview" = "Powrót do bieżącej wersji" +"version.list" = "Historia wersji" +"version.preview" = "Wyświetl wersję" +"version.rename" = "Zmień nazwę wersji" +"version.restore" = "Przywróć wersję" +"view.assets" = "Panel zasobów" +"view.comments" = "Pokaż komentarze" +"view.contextMenu" = "Pokaż menu kontekstowe" +"view.design" = "Panel projektu" +"view.designPrototype" = "Przełącz między Projektem a Prototypem" +"view.devMode" = "Tryb programisty" +"view.frameOutlines" = "Kontury ramek" +"view.fullscreen" = "Tryb pełnoekranowy" +"view.get" = "Pobierz widok" +"view.layers" = "Panel warstw" +"view.layoutGrids" = "Siatki układu" +"view.leftSidebar" = "Pokaż/Ukryj lewy pasek boczny" +"view.maskOutlines" = "Kontury masek" +"view.multiplayerCursors" = "Kursory innych użytkowników" +"view.nextFrame" = "Przejdź do następnej ramki" +"view.nextPage" = "Następna strona" +"view.nextRegion" = "Przejdź do następnego obszaru" +"view.outlines" = "Pokaż kontury" +"view.outlinesHiddenLayers" = "Pokaż kontury ukrytych warstw" +"view.outlinesObjectBounds" = "Pokaż granice obiektów" +"view.outlinesSelection" = "Pokaż kontury zaznaczenia" +"view.pan" = "Przesuń widok" +"view.panelWidths" = "Zresetuj szerokości paneli" +"view.pixelGrid" = "Siatka pikseli" +"view.pixelPreview" = "Podgląd pikseli" +"view.prevFrame" = "Przejdź do poprzedniej ramki" +"view.prevPage" = "Poprzednia strona" +"view.previousRegion" = "Przejdź do poprzedniego obszaru" +"view.proofSrgb" = "Symulacja sRGB (Soft-proof)" +"view.prototype" = "Panel prototypu" +"view.rulers" = "Linijki" +"view.set" = "Ustaw widok" +"view.slices" = "Plastry" +"view.snapGeometry" = "Przyciągaj do geometrii" +"view.snapObjects" = "Przyciągaj do obiektów" +"view.snapPixel" = "Przyciągaj do pikseli" +"view.themeDark" = "Ciemny motyw" +"view.themeLight" = "Jasny motyw" +"view.themeSystem" = "Motyw zgodny z systemem" +"view.toggleUi" = "Pokaż/Ukryj interfejs" +"view.zoom100" = "Powiększenie 100%" +"view.zoom200" = "Powiększenie 200%" +"view.zoom50" = "Powiększenie 50%" +"view.zoomAt" = "Narzędzie lupy" +"view.zoomFit" = "Dopasuj do ekranu" +"view.zoomIn" = "Powiększ" +"view.zoomOut" = "Pomniejsz" +"view.zoomSelection" = "Powiększ do zaznaczenia" +"widget.click" = "Kliknij widżet" +"widget.insert" = "Wstaw widżet" +"widget.list" = "Lista widżetów" +"window.focus" = "Przesuń okno na wierzch" +"window.new" = "Nowe okno" + +[menu] +"bool.exclude" = "Wyklucz" +"bool.flatten" = "Spłaszcz" +"bool.intersect" = "Część wspólna" +"bool.subtract" = "Odejmij" +"bool.union" = "Połącz" +"file.duplicate" = "Powiel" +"file.import" = "Importuj z pliku…" +"file.rename" = "Zmień nazwę" +"libraries.open" = "Otwórz biblioteki" +"select.sameEffect" = "Efekt" +"select.sameFill" = "Wypełnienie" +"select.sameFont" = "Czcionka" +"select.sameInstance" = "Instancja" +"select.sameProperties" = "Właściwości" +"select.sameStroke" = "Obrys" +"select.sameText" = "Właściwości tekstu" +"text.alignCenter" = "Wyrównaj do środka" +"text.alignJustify" = "Wyjustuj" +"text.alignLeft" = "Wyrównaj do lewej" +"text.alignRight" = "Wyrównaj do prawej" +"view.assets" = "Zasoby" +"view.design" = "Projekt" +"view.layers" = "Warstwy" +"view.outlines" = "Pokaż kontury" +"view.outlinesHiddenLayers" = "Uwzględnij ukryte warstwy" +"view.outlinesObjectBounds" = "Uwzględnij granice obiektów" +"view.outlinesSelection" = "Pokaż kontury zaznaczenia" +"view.prototype" = "Prototyp" +"view.themeDark" = "Ciemny" +"view.themeLight" = "Jasny" +"view.themeSystem" = "Zgodny z systemem" diff --git a/crates/ui-egui/locales/pt-BR.toml b/crates/ui-egui/locales/pt-BR.toml index d970db14..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" @@ -304,6 +308,7 @@ status = "draft — needs native review" "Corner smoothing" = "Suavização de cantos" "Couldn't download the variables" = "Não foi possível baixar as variáveis" "Couldn't reach {server}: showing what this computer remembers." = "Não foi possível alcançar {server}: mostrando o que este computador lembra." +"Couldn't read {name}." = "Não foi possível ler {name}." "Couldn't save automatically: {error}" = "Não foi possível salvar automaticamente: {error}" "Count" = "Quantidade" "Create" = "Criar" @@ -358,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" @@ -368,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" @@ -384,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" @@ -414,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" @@ -544,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" @@ -554,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" @@ -709,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)" @@ -765,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" @@ -827,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." @@ -860,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" @@ -891,8 +906,10 @@ status = "draft — needs native review" "PDF print settings" = "Configurações de impressão em PDF" "Padding" = "Espaçamento interno" "Page" = "Página" +"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" @@ -934,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" @@ -980,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" @@ -1024,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" @@ -1079,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" @@ -1148,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" @@ -1205,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" @@ -1217,17 +1242,22 @@ status = "draft — needs native review" "Start ascending from / stop descending at" = "Contar para cima a partir de / para baixo até" "Start from a blank canvas" = "Comece com uma tela em branco" "Start on path" = "Início no caminho" +"Start on the Home screen" = "Começar no Início" "Start point" = "Ponto inicial" "Start sharing" = "Começar a compartilhar" "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" @@ -1271,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" @@ -1301,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" @@ -1317,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" @@ -1339,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" @@ -1402,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" @@ -1573,6 +1610,7 @@ status = "draft — needs native review" "{name} is forgotten on this computer; the file stays on {server}" = "{name} foi esquecido neste computador; o arquivo continua em {server}" "{name} is on the server." = "{name} está no servidor." "{name} is spotlighting" = "{name} está em destaque" +"{name} is too large to open in the browser (over 512 MB)." = "{name} é grande demais para abrir no navegador (mais de 512 MB)." "{name} was removed from the server" = "{name} foi removido do servidor" "{name} · deleted on main" = "{name} · excluído no principal" "{name} · deleted on the branch" = "{name} · excluído na branch" @@ -1636,6 +1674,120 @@ status = "draft — needs native review" "⌘ click" = "⌘ clique" "⌘ scroll" = "⌘ rolar" "⌥ drag" = "⌥ arrastar" +"A responsive product page: hero, features, figures, a quote and a footer" = "Uma página de produto responsiva: destaque, recursos, dados, depoimento e rodapé" +"A title, an agenda, a big figure, a chart and a closing slide" = "Um título, uma pauta, um número em destaque, um gráfico e um slide de encerramento" +"A to Z" = "De A a Z" +"Adding to {name}" = "Adicionando a {name}" +"All the fonts this file uses are installed" = "Todas as fontes usadas neste arquivo estão instaladas" +"Alphabetical" = "Alfabética" +"An analytics screen on a grid: figures, charts drawn from vectors and a table" = "Uma tela de análise em grade: dados, gráficos vetoriais e uma tabela" +"Apps" = "Aplicativos" +"Apr" = "abr." +"Aug" = "ago." +"Bottom padding" = "Espaçamento interno inferior" +"Bound to {name}. Click to detach the variable." = "Vinculada a {name}. Clique para desvincular a variável." +"Checking the fonts installed on this computer…" = "Verificando as fontes instaladas neste computador…" +"Colour and text styles, Light and Dark variables, buttons, inputs and cards" = "Estilos de cor e texto, variáveis Claro e Escuro, botões, campos e cartões" +"Convert to frame" = "Converter em quadro" +"Convert to section" = "Converter em seção" +"Created" = "Criado" +"Dashboard" = "Painel" +"Date created" = "Data de criação" +"Dec" = "dez." +"Design system starter" = "Ponto de partida para sistema de design" +"Draw a frame" = "Desenhe um quadro" +"Drop a .figcraft, .fig, .json, .svg or image file." = "Solte um arquivo .figcraft, .fig, .json, .svg ou uma imagem." +"Drop to import" = "Solte para importar" +"Drop to open" = "Solte para abrir" +"Edited {when}" = "Editado {when}" +"Export frames and layers as PNG, JPG, SVG or PDF" = "Exporte quadros e camadas como PNG, JPG, SVG ou PDF" +"Feb" = "fev." +"File not found" = "Arquivo não encontrado" +"Four screens of a trail app with components and a prototype flow" = "Quatro telas de um aplicativo de trilhas com componentes e um fluxo de protótipo" +"Getting started" = "Primeiros passos" +"Greyscale components and two example screens to sketch a flow with" = "Componentes em escala de cinza e duas telas de exemplo para esboçar um fluxo" +"Hide these tips (Getting started in the Help menu shows them again)" = "Ocultar estas dicas (Primeiros passos no menu Ajuda as exibe novamente)" +"Hide when publishing" = "Ocultar ao publicar" +"Icon components on a 24 px grid with keylines and a size preview" = "Componentes de ícone em uma grade de 24 px, com linhas-guia e prévia de tamanho" +"Icon set starter" = "Ponto de partida para conjunto de ícones" +"Individual padding" = "Espaçamento interno individual" +"Interface size" = "Tamanho da interface" +"Jan" = "jan." +"Jul" = "jul." +"Jun" = "jun." +"Kits" = "Kits" +"Landing page" = "Página de apresentação" +"Last modified" = "Modificado por último" +"Left padding" = "Espaçamento interno à esquerda" +"Link frames on the Prototype tab, then present the flow" = "Vincule os quadros na aba Protótipo e apresente o fluxo" +"Make a component" = "Criar um componente" +"Mar" = "mar." +"May" = "mai." +"Missing fonts" = "Fontes ausentes" +"Mobile app" = "Aplicativo para celular" +"Newest first" = "Mais recentes primeiro" +"No files or templates match your search" = "Nenhum arquivo ou modelo corresponde à sua busca" +"No other pages" = "Não há outras páginas" +"No templates match your search" = "Nenhum modelo corresponde à sua busca" +"Nov" = "nov." +"Oct" = "out." +"Oldest first" = "Mais antigos primeiro" +"One announcement in five common sizes: square, portrait, story, link and banner" = "Um anúncio em cinco tamanhos comuns: quadrado, retrato, story, link e banner" +"Opening the file…" = "Abrindo o arquivo…" +"Opening {n} files as new files" = { one = "Abrindo como um novo arquivo", other = "Abrindo {n} arquivos como novos arquivos" } +"Order" = "Ordem" +"Other" = "Outro" +"Pick the Frame tool, then drag on the canvas or choose a device size" = "Selecione a ferramenta Quadro e arraste na tela ou escolha o tamanho de um dispositivo" +"Plugin" = "Plugin" +"Plugins & widgets" = "Plugins e widgets" +"Presentation slides" = "Slides de apresentação" +"Playground" = "Área de testes" +"Relative to your system's display scaling (100% is the system's own size). Document zoom is separate." = "Em relação à escala de exibição do sistema (100% corresponde ao tamanho definido pelo sistema). O zoom do documento é independente." +"Remove from recents" = "Remover dos recentes" +"Reorder selection" = "Reordenar seleção" +"Right padding" = "Espaçamento interno à direita" +"Search templates" = "Buscar modelos" +"See all templates" = "Ver todos os modelos" +"Select layers and they space themselves as their content changes" = "Selecione camadas e elas ajustarão o espaçamento conforme o conteúdo mudar" +"Search files and templates" = "Buscar arquivos e modelos" +"Search results" = "Resultados da busca" +"Search pages" = "Buscar páginas" +"Sep" = "set." +"Share with a code" = "Compartilhar com um código" +"Show all templates" = "Mostrar todos os modelos" +"Show in Explorer" = "Mostrar no Explorador de Arquivos" +"Show in Finder" = "Mostrar no Finder" +"Show in folder" = "Mostrar na pasta" +"Social and slides" = "Redes sociais e slides" +"Social post sizes" = "Tamanhos de publicação para redes sociais" +"Sort by" = "Ordenar por" +"Sort by {column}" = "Ordenar por {column}" +"Start from a template" = "Começar com um modelo" +"Start sharing with the Share button, then pass on the code it gives" = "Comece a compartilhar pelo botão Compartilhar e envie o código gerado" +"Templates" = "Modelos" +"The destination document is unavailable. Nothing was imported." = "O documento de destino está indisponível. Nada foi importado." +"The destination document was closed. Nothing was imported." = "O documento de destino foi fechado. Nada foi importado." +"The destination page was removed. Nothing was imported." = "A página de destino foi removida. Nada foi importado." +"The window is too small for {size}%, so the interface is drawn at {drawn}%. Make the window larger to use {size}%." = "A janela é pequena demais para {size}%, então a interface é exibida em {drawn}%. Aumente a janela para usar {size}%." +"This file uses fonts that aren't installed on this computer. Their text is drawn in a fallback font until you install them." = "Este arquivo usa fontes que não estão instaladas neste computador. O texto será exibido com uma fonte substituta até que você as instale." +"Top padding" = "Espaçamento interno superior" +"Try another search, or browse templates." = "Tente outra busca ou explore os modelos." +"Try another word, or look at every kind." = "Tente outra palavra ou veja todos os tipos." +"Turn a layer into a component, reuse it, and change every copy at once" = "Transforme uma camada em componente, reutilize-a e altere todas as cópias de uma só vez" +"Use your browser's zoom to change the interface size." = "Use o zoom do navegador para alterar o tamanho da interface." +"Uses variables: each layer shows them in its own mode" = "Usa variáveis: cada camada as exibe em seu próprio modo" +"Websites" = "Sites" +"What you try here changes this preview and the code below, not the file" = "O que você experimentar aqui altera esta prévia e o código abaixo, não o arquivo" +"Widget" = "Widget" +"Wireframe kit" = "Kit de wireframe" +"Z to A" = "De Z a A" +"just now" = "agora mesmo" +"only in a text style or variable" = "somente em um estilo de texto ou variável" +"slot layers without a matching slot property were imported as frames" = "camadas de slot sem uma propriedade de slot correspondente foram importadas como quadros" +"yesterday" = "ontem" +"{month} {day}, {year}" = "{day} de {month} de {year}" +"{n} text layers" = { one = "{n} camada de texto", other = "{n} camadas de texto" } +"{percent}%" = "{percent}%" [commands] "align.bottom" = "Alinhar embaixo" @@ -1663,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" @@ -1785,7 +1946,9 @@ status = "draft — needs native review" "file.exportLayers" = "Exportar camadas" "file.exportSvg" = "Exportar seleção como SVG" "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" @@ -1922,6 +2085,7 @@ status = "draft — needs native review" "pref.reduceMotion" = "Reduzir movimento" "pref.renameDuplicates" = "Renomear camadas duplicadas" "pref.showDimensions" = "Mostrar dimensões nos objetos" +"pref.startOnHome" = "Começar no Início" "pref.userName" = "Seu nome nos comentários" "proto.action.add" = "Adicionar ação de protótipo" "proto.action.move" = "Reordenar ação de protótipo" @@ -2048,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" @@ -2063,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" @@ -2127,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 8d371cbe..baba56fd 100644 --- a/crates/ui-egui/locales/ru.toml +++ b/crates/ui-egui/locales/ru.toml @@ -1,5 +1,6 @@ # FigCraft interface strings: Russian. # Wording reviewed and approved by VanTan. +# Strings added since that review (2026-10-20) are a machine draft awaiting review. # Keep Frame, Dev Mode and Smart animate as familiar tool names. # See docs/translating.md; plural forms follow Russian integer rules. @@ -16,6 +17,7 @@ name = "Русский" "@{login}: {prs} PRs, {commits} commits, +{added} / −{deleted} lines (Δ {delta}), +{bin_added} / −{bin_deleted} binary assets, {first} – {last}" = "@{login}: PR: {prs}, коммитов: {commits}, +{added} / −{deleted} строк (Δ {delta}), +{bin_added} / −{bin_deleted} бинарных ресурсов, {first} – {last}" "A collection of this file with these modes and variables: override their values per mode and add variables of its own" = "Коллекция этого файла с этими режимами и переменными: переопределяйте значения для каждого режима и добавляйте собственные переменные" "A .figcraft file with this name already exists. Choose its full name to confirm replacement." = "Файл .figcraft с таким именем уже существует. Чтобы подтвердить замену, выберите его полное имя." +"A layer on its own aligns to its frame: select more layers, or one in a frame" = "Отдельный слой выравнивается по своему фрейму: выделите больше слоёв или слой внутри фрейма." "A responsive product page: hero, features, figures, a quote and a footer" = "Адаптивная страница продукта: главный блок, возможности, показатели, цитата и подвал" "A spring plays until it settles: its mass, stiffness and damping set how long" = "Пружинная анимация длится до затухания: её время задают масса, жёсткость и демпфирование" "A title, an agenda, a big figure, a chart and a closing slide" = "Заголовок, повестка, крупный показатель, диаграмма и заключительный слайд" @@ -65,6 +67,7 @@ name = "Русский" "Add variant with a new value" = "Добавить вариант с новым значением" "Added" = "Добавлено" "Added the library \"{name}\"" = "Добавлена библиотека «{name}»" +"Adding to {name}" = "Добавление в {name}" "Adjust" = "Коррекция" "Adjustment presets" = "Пресеты коррекции" "Advanced" = "Дополнительно" @@ -88,6 +91,7 @@ name = "Русский" "All components" = "Все компоненты" "All fonts" = "Все шрифты" "All supported properties" = "Все поддерживаемые свойства" +"All the fonts this file uses are installed" = "Все шрифты, которые использует этот файл, установлены" "All variables" = "Все переменные" "Alpha" = "Альфа" "Alphabetical" = "По алфавиту" @@ -121,6 +125,7 @@ name = "Русский" "Archived" = "В архиве" "Arrange" = "Упорядочить" "Arrow" = "Стрелка" +"Arrow at the end" = "Стрелка на конце" "As typed" = "Как введено" "Asking the server…" = "Запрос к серверу…" "Assets" = "Ресурсы" @@ -168,6 +173,7 @@ name = "Русский" "Both directions" = "Оба направления" "Bottom" = "Снизу" "Bottom left radius" = "Радиус нижнего левого угла" +"Bottom padding" = "Нижний внутренний отступ" "Bottom right radius" = "Радиус нижнего правого угла" "Branch" = "Ветка" "Branch archived" = "Ветка архивирована" @@ -194,7 +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}" @@ -241,7 +250,6 @@ name = "Русский" "Close tab" = "Закрыть вкладку" "Close version history" = "Закрыть историю версий" "Close when clicking outside" = "Закрывать при нажатии снаружи" -"Close without saving" = "Закрыть без сохранения" "Code" = "Код" "Code syntax" = "Синтаксис кода" "Collapse" = "Свернуть" @@ -283,6 +291,7 @@ name = "Русский" "Connected" = "Подключено" "Connected to code: {from}" = "Связано с кодом: {from}" "Connection lost, reconnecting (attempt {attempt})…" = "Соединение потеряно, переподключение (попытка {attempt})…" +"Connector" = "Соединитель" "Constrain proportions" = "Сохранять пропорции" "Constraints" = "Ограничения" "Content" = "Содержимое" @@ -293,6 +302,8 @@ name = "Русский" "Contributors" = "Участники" "Contributors: {people} · Commits: {commits}" = "Участники: {people} · Коммиты: {commits}" "Controller" = "Контроллер" +"Convert to frame" = "Преобразовать во Frame" +"Convert to section" = "Преобразовать в секцию" "Cool" = "Холодный" "Copied as CSS" = "Скопировано как CSS" "Copied as PNG" = "Скопировано как PNG" @@ -310,7 +321,10 @@ name = "Русский" "Copy {value}" = "Копировать {value}" "Corner radius" = "Радиус углов" "Corner smoothing" = "Сглаживание углов" +"Couldn't download the variables" = "Не удалось загрузить переменные" "Couldn't reach {server}: showing what this computer remembers." = "Не удалось связаться с {server}: показаны данные, сохранённые на этом компьютере." +"Couldn't read {name}." = "Не удалось прочитать {name}." +"Couldn't save automatically: {error}" = "Не удалось сохранить автоматически: {error}" "Count" = "Количество" "Create" = "Создать" "Create a variable collection first" = "Сначала создайте коллекцию переменных" @@ -368,6 +382,7 @@ name = "Русский" "Description" = "Описание" "Description (optional)" = "Описание (необязательно)" "Design" = "Дизайн" +"Design panel" = "Панель дизайна" "Design system starter" = "Основа дизайн-системы" "Desktop" = "Компьютер" "Destination" = "Назначение" @@ -395,6 +410,7 @@ name = "Русский" "Distribute horizontal spacing" = "Распределить по горизонтали" "Distribute vertical spacing" = "Распределить по вертикали" "Divider" = "Разделитель" +"Don't Save" = "Не сохранять" "Don't truncate" = "Не обрезать" "Done" = "Готово" "Done editing" = "Завершить редактирование" @@ -406,7 +422,10 @@ name = "Русский" "Draw" = "Рисование" "Draw a frame" = "Нарисовать Frame" "Drawing tools" = "Инструменты рисования" +"Drop a .figcraft, .fig, .json, .svg or image file." = "Перетащите файл .figcraft, .fig, .json, .svg или изображение." "Drop shadow" = "Внешняя тень" +"Drop to import" = "Отпустите, чтобы импортировать" +"Drop to open" = "Отпустите, чтобы открыть" "Dual-licensed MIT OR Apache-2.0. Made by the ArtCraft team and community." = "Двойная лицензия MIT OR Apache-2.0. Создано командой и сообществом ArtCraft." "Duo" = "Дуо" "Duplicate" = "Дублировать" @@ -427,6 +446,7 @@ name = "Русский" "Effect settings" = "Настройки эффекта" "Effect styles" = "Стили эффектов" "Effects" = "Эффекты" +"Elbow" = "Ломаная" "Ellipse" = "Эллипс" "Ellipses" = "Эллипсы" "Else" = "Иначе" @@ -476,6 +496,7 @@ name = "Русский" "False" = "Ложь" "Feb" = "февр." "Fetching from the server…" = "Загрузка с сервера…" +"FigCraft couldn't open its window: {error}" = "FigCraft не смог открыть своё окно: {error}" "FigCraft doesn't support every feature of these files yet. The notes list what was left out or changed in the imported copy; the original file is unchanged." = "FigCraft пока поддерживает не все возможности этих файлов. В замечаниях перечислено, что было пропущено или изменено в импортированной копии; исходный файл не меняется." "File" = "Файл" "File name" = "Имя файла" @@ -562,6 +583,7 @@ name = "Русский" "Hanging list" = "Выступающий список" "Hanging punctuation" = "Висячая пунктуация" "Hard light" = "Жёсткий свет" +"Heart" = "Сердце" "Height" = "Высота" "Help" = "Справка" "Help and resources" = "Справка и ресурсы" @@ -574,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" @@ -597,6 +620,7 @@ name = "Русский" "Icon components on a 24 px grid with keylines and a size preview" = "Компоненты иконок на сетке 24 px с направляющими и предпросмотром размеров" "Icon set starter" = "Основа набора иконок" "If" = "Если" +"If it keeps happening, please report it at {link} with this message." = "Если это повторяется, сообщите об этом по адресу {link}, приложив это сообщение." "If {condition}" = "Если {condition}" "Ignore auto layout (absolute position)" = "Игнорировать автолейаут (абсолютная позиция)" "Image" = "Изображение" @@ -618,6 +642,7 @@ name = "Русский" "Included" = "Включено" "Included in layout" = "Участвует в компоновке" "Individual corners" = "Углы по отдельности" +"Individual padding" = "Внутренние отступы по отдельности" "Individual strokes" = "Обводки по отдельности" "Ink" = "Чернила" "Inner shadow" = "Внутренняя тень" @@ -639,6 +664,7 @@ name = "Русский" "Interaction" = "Взаимодействие" "Interactions" = "Взаимодействия" "Interface design and prototyping. A free, open-source, clean-room design tool written from scratch in Rust, part of the ArtCraft Crafting Apps." = "Дизайн интерфейсов и прототипирование. Бесплатный инструмент с открытым исходным кодом, независимо созданный с нуля на Rust, из семейства ArtCraft Crafting Apps." +"Interface size" = "Размер интерфейса" "Intersect" = "Пересечение" "Intersects" = "Пересечения" "Invalid color" = "Недопустимый цвет" @@ -688,6 +714,7 @@ name = "Русский" "Leaves" = "Листья" "Left" = "Слева" "Left & right" = "Слева и справа" +"Left padding" = "Левый внутренний отступ" "Left side" = "Левая сторона" "Left side width" = "Ширина левой стороны" "Letter case" = "Регистр букв" @@ -739,6 +766,7 @@ name = "Русский" "Mark as resolved" = "Отметить как решённое" "Mark as unread" = "Отметить как непрочитанное" "Mark as unresolved" = "Отметить как нерешённое" +"Marker" = "Маркер" "Mask" = "Маска" "Mass · Stiffness · Damping" = "Масса · Жёсткость · Демпфирование" "Match (empty: the whole name)" = "Совпадение (пусто: всё имя)" @@ -760,6 +788,7 @@ name = "Русский" "Mirror angle and length" = "Отражать угол и длину" "Mirroring" = "Зеркальность" "Missing" = "Отсутствует" +"Missing fonts" = "Отсутствующие шрифты" "Missing layer" = "Слой отсутствует" "Missing style" = "Стиль отсутствует" "Missing variable" = "Переменная отсутствует" @@ -797,6 +826,7 @@ name = "Русский" "Named instance" = "Именованный экземпляр" "Navigate to" = "Перейти к" "Nested instance" = "Вложенный экземпляр" +"New board" = "Новая доска" "New design file" = "Новый файл дизайна" "Newest first" = "Сначала новые" "Next frame" = "Следующий Frame" @@ -900,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" @@ -910,10 +941,13 @@ name = "Русский" "Open variables ({count})" = "Открыть переменные ({count})" "Opened as a new document" = "Открыт как новый документ" "Opened “{name}” in a new tab" = "«{name}» открыт в новой вкладке" +"Opening the file…" = "Открытие файла…" +"Opening {n} files as new files" = { one = "Открытие {n} файла как нового", few = "Открытие {n} файлов как новых", many = "Открытие {n} файлов как новых", other = "Открытие {n} файлов как новых" } "Open|comments" = "Открытые" "Options" = "Параметры" "Orange" = "Оранжевый" "Order" = "Порядок" +"Other" = "Другое" "Other apps may not open every .fig file exported here." = "Другие приложения могут открыть не каждый экспортированный здесь файл .fig." "Others lose access and keep their copy; the file stays on the server for you" = "Другие участники потеряют доступ, сохранив свои копии; файл останется на сервере для вас" "Others type this under Share ▸ Join with a code" = "Другие участники вводят это в «Поделиться» ▸ «Подключиться по коду»" @@ -932,8 +966,10 @@ name = "Русский" "PDF print settings" = "Настройки печати PDF" "Padding" = "Внутренние отступы" "Page" = "Страница" +"Page deleted" = "Страница удалена" "Pages" = "Страницы" "Pages added to the document" = "Страницы добавлены в документ" +"Pink" = "Розовый" "Placed on the current page" = "Размещено на текущей странице" "Pages: {pages}" = "Страницы: {pages}" "Paint bucket" = "Заливка области" @@ -972,10 +1008,13 @@ name = "Русский" "Place a shape or frame (its tool chosen)" = "Разместить фигуру или Frame (выбранным инструментом)" "Place automatically" = "Разместить автоматически" "Place image" = "Разместить изображение" +"Plugin" = "Плагин" +"Plugins & widgets" = "Плагины и виджеты" "Plugins run in FigCraft's sandbox, without files, network or clock. Install only plugins you trust." = "Плагины работают в песочнице FigCraft без доступа к файлам, сети и часам. Устанавливайте только плагины, которым доверяете." "Plugins" = "Плагины" "Plus darker" = "Линейное затемнение" "Plus lighter" = "Линейное осветление" +"Plus one" = "Плюс один" "Point" = "Точка" "Points" = "Точки" "Polygon" = "Многоугольник" @@ -1024,6 +1063,7 @@ name = "Русский" "Push" = "Вытеснение" "Push changes to main component" = "Перенести изменения в основной компонент" "Put text on path" = "Разместить текст на пути" +"Question" = "Вопрос" "R" = "R" "Radial" = "Радиальный" "Radius" = "Радиус" @@ -1041,6 +1081,7 @@ name = "Русский" "Reduce motion" = "Уменьшить движение" "Refraction" = "Преломление" "Regular expression" = "Регулярное выражение" +"Relative to your system's display scaling (100% is the system's own size). Document zoom is separate." = "Относительно масштаба экрана в системе (100% — собственный размер системы). Масштаб документа задаётся отдельно." "Remove" = "Удалить" "Remove all" = "Удалить всё" "Remove annotation" = "Удалить аннотацию" @@ -1064,6 +1105,7 @@ name = "Русский" "Remove the else and its actions" = "Удалить «Иначе» и его действия" "Remove this condition and its actions" = "Удалить это условие и его действия" "Remove this file from the server? It can't be shared or opened from there again. Your copy stays open here." = "Удалить файл с сервера? Его больше нельзя будет открывать и публиковать оттуда. Ваша копия останется открытой здесь." +"Remove {name} from session" = "Убрать {name} из сессии" "Remove {name} from {server}?" = "Удалить {name} с {server}?" "Removed" = "Удалено" "Removed from the server" = "Удалено с сервера" @@ -1078,6 +1120,7 @@ name = "Русский" "Rename on the server…" = "Переименовать на сервере…" "Rename page" = "Переименовать страницу" "Rename to" = "Новое имя" +"Reorder selection" = "Изменить порядок выделения" "Replace all" = "Заменить всё" "Replace mixed export settings" = "Заменить разные настройки экспорта" "Replace with" = "Заменить на" @@ -1113,6 +1156,7 @@ name = "Русский" "Review and merge" = "Проверить и объединить" "Ribbon" = "Лента" "Right" = "Справа" +"Right padding" = "Правый внутренний отступ" "Right side" = "Правая сторона" "Right side width" = "Ширина правой стороны" "Rotate 90°" = "Повернуть на 90°" @@ -1123,6 +1167,7 @@ name = "Русский" "Rotation jitter" = "Колебание угла" "Round" = "Круглое" "Rounded" = "Скруглённый" +"Rounded rectangle" = "Скруглённый прямоугольник" "Row" = "Строка" "Row gap" = "Интервал между строками" "Row sizes" = "Размеры строк" @@ -1132,6 +1177,8 @@ name = "Русский" "Rulers" = "Линейки" "Run" = "Запустить" "Run {label}" = "Запустить {label}" +"Save files automatically" = "Автоматически сохранять файлы" +"Search pages" = "Поиск страниц" "Search templates" = "Поиск шаблонов" "See all templates" = "Все шаблоны" "Select layers and they space themselves as their content changes" = "Выделите слои: интервалы между ними будут подстраиваться при изменении содержимого" @@ -1197,6 +1244,7 @@ name = "Русский" "Shape" = "Фигура" "Shape builder" = "Конструктор фигур" "Shape tools" = "Инструменты фигур" +"Shapes" = "Фигуры" "Share" = "Поделиться" "Share this file" = "Поделиться файлом" "Share with a code" = "Поделиться по коду" @@ -1263,10 +1311,13 @@ name = "Русский" "Space+drag" = "Пробел+перетаскивание" "Spacing" = "Интервал" "Spam, stolen work, abuse…" = "Спам, украденные работы, оскорбления…" +"Speech bubble" = "Выноска" "Splay" = "Расхождение" "Spotlight me" = "Показывать мой экран всем" "Spread" = "Размах" "Square" = "Квадрат" +"Stamp" = "Штамп" +"Stamps" = "Штампы" "Standard" = "Стандартный" "Standard ligatures" = "Стандартные лигатуры" "Star" = "Звезда" @@ -1276,18 +1327,23 @@ name = "Русский" "Start from a blank canvas" = "Начать с пустого холста" "Start from a template" = "Начать с шаблона" "Start on path" = "Начало на пути" +"Start on the Home screen" = "Начинать с главной" "Start point" = "Начальная точка" "Start sharing" = "Включить общий доступ" "Start sharing with the Share button, then pass on the code it gives" = "Включите общий доступ кнопкой «Поделиться», затем передайте полученный код" "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" = "Строка" @@ -1332,11 +1388,15 @@ name = "Русский" "Text" = "Текст" "Text align" = "Выравнивание текста" "Text content" = "Содержимое текста" +"Text size" = "Размер текста" "Text style" = "Текстовый стиль" "Text styles" = "Текстовые стили" "Texts" = "Тексты" "Texture" = "Текстура" "The canvas previews colors in sRGB; exported PNG, JPG and WebP files are tagged Display P3." = "На холсте цвета отображаются в sRGB; экспортированные PNG, JPG и WebP помечены профилем Display P3." +"The destination document is unavailable. Nothing was imported." = "Документ назначения недоступен. Ничего не импортировано." +"The destination document was closed. Nothing was imported." = "Документ назначения закрыт. Ничего не импортировано." +"The destination page was removed. Nothing was imported." = "Страница назначения удалена. Ничего не импортировано." "The file is deleted from the server: nobody can open or join it from there again, and this can't be undone. Copies that are open stay with whoever has them." = "Файл удаляется с сервера: его больше нельзя будет открыть или подключиться к нему оттуда. Отменить это нельзя. Уже открытые копии остаются у участников." "The file now holds another library" = "Теперь файл содержит другую библиотеку" "The file now holds another library (\"{name}\")" = "Теперь файл содержит другую библиотеку («{name}»)" @@ -1346,6 +1406,7 @@ name = "Русский" "The selection uses several text styles: click to apply one" = "В выделении несколько текстовых стилей: нажмите, чтобы применить один" "The server deletes this file if nobody opens it before {date}." = "Сервер удалит файл, если никто не откроет его до {date}." "The whole file" = "Весь файл" +"The window is too small for {size}%, so the interface is drawn at {drawn}%. Make the window larger to use {size}%." = "Окно слишком мало для {size}%, поэтому интерфейс отрисовывается в масштабе {drawn}%. Увеличьте окно, чтобы использовать {size}%." "Theme" = "Тема" "There's no destination for that action" = "Для этого действия не задано назначение" "Thickness" = "Толщина" @@ -1357,11 +1418,14 @@ name = "Русский" "This file" = "Этот файл" "This file has unsaved changes" = "В файле есть несохранённые изменения" "This file is on {server}, open only to you. Share it to let others in with a new code." = "Файл находится на {server} и доступен только вам. Включите общий доступ, чтобы пригласить других по новому коду." +"This file is too large to join here (up to {mb} MB)" = "Этот файл слишком большой, чтобы подключиться здесь (до {mb} МБ)" +"This file uses fonts that aren't installed on this computer. Their text is drawn in a fallback font until you install them." = "В этом файле используются шрифты, не установленные на этом компьютере. Их текст отрисовывается запасным шрифтом, пока вы их не установите." "This frame is new on the branch." = "Этот Frame добавлен в ветке." "This frame isn't on the branch." = "Этого Frame нет в ветке." "This server is open to the public: no key needed." = "Сервер общедоступен: ключ не нужен." "This server needs its key: add ?key=… to the address (`figcraft-server key` prints it on the server's machine)." = "Сервер требует ключ: добавьте ?key=… к адресу (`figcraft-server key` выводит его на компьютере сервера)." "This server takes files only with its key: see Advanced." = "Сервер принимает файлы только с ключом: см. «Дополнительно»." +"Thumbs up" = "Палец вверх" "Tidy up" = "Упорядочить" "Tile" = "Повторение" "Tile type" = "Тип повторения" @@ -1376,8 +1440,10 @@ name = "Русский" "Top" = "Сверху" "Top & bottom" = "Сверху и снизу" "Top left radius" = "Радиус верхнего левого угла" +"Top padding" = "Верхний внутренний отступ" "Top right radius" = "Радиус верхнего правого угла" "Transform" = "Трансформация" +"Triangle" = "Треугольник" "Triangle arrow" = "Треугольная стрелка" "Trigger" = "Триггер" "True" = "Истина" @@ -1403,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" = "Вверх" @@ -1413,6 +1479,8 @@ name = "Русский" "Update from selection" = "Обновить из выделения" "Updated {components} and {styles}" = "Обновлено: {components} и {styles}" "Updated {name}" = "Обновлено: {name}" +"Updating the graphics driver often helps. To use OpenGL only, start FigCraft from a Command Prompt with:" = "Часто помогает обновление драйвера видеокарты. Чтобы использовать только OpenGL, запустите FigCraft из командной строки с параметром:" +"Updating the graphics driver often helps. WGPU_BACKEND (vulkan, gl, metal) names another graphics API to try." = "Часто помогает обновление драйвера видеокарты. Переменная WGPU_BACKEND (vulkan, gl, metal) задаёт другой графический API, который можно попробовать." "Uploads this file to a FigCraft server and keeps this tab in sync with it. You get a link to edit and a link to view." = "Загружает файл на сервер FigCraft и синхронизирует эту вкладку с ним. Вы получите ссылки для редактирования и просмотра." "Uppercase" = "Прописные" "Use a fixed value" = "Использовать фиксированное значение" @@ -1422,6 +1490,7 @@ name = "Русский" "Use number keys for opacity" = "Задавать непрозрачность цифровыми клавишами" "Use the .figcraft or .fig extension." = "Используйте расширение .figcraft или .fig." "Use the layer's fill" = "Использовать заливку слоя" +"Use your browser's zoom to change the interface size." = "Изменяйте размер интерфейса масштабом браузера." "Used in {files} · {instances}" = "Используется в {files} · {instances}" "Username" = "Имя пользователя" "Value" = "Значение" @@ -1456,6 +1525,7 @@ name = "Русский" "Visibility" = "Видимость" "Visible" = "Видимый" "Vivid" = "Яркий" +"WGPU_BACKEND is set and names the graphics API to use; unsetting it lets FigCraft choose." = "Переменная WGPU_BACKEND задана и указывает используемый графический API; если её убрать, FigCraft выберет сам." "Warm" = "Тёплый" "Watch" = "Часы" "Wavy" = "Волнистый" @@ -1475,6 +1545,7 @@ name = "Русский" "White" = "Белый" "Who runs it, its terms and how long it keeps files" = "Оператор, условия и срок хранения файлов" "Why it changed" = "Причина изменения" +"Widget" = "Виджет" "Widgets" = "Виджеты" "Width" = "Ширина" "Width point" = "Точка ширины" @@ -1572,6 +1643,7 @@ name = "Русский" "nobody else here yet" = "здесь пока больше никого нет" "non-default slot settings are not represented and were skipped" = "нестандартные настройки слотов не передаются и пропущены" "now" = "сейчас" +"only in a text style or variable" = "только в текстовом стиле или переменной" "or" = "или" "point stroke joins were preserved; dashed or variable-width strokes render with the layer join" = "соединения обводки в точках сохранены; пунктирные обводки и обводки переменной ширины рисуются с соединением слоя" "preferred slot component keys cannot be resolved to file nodes and were skipped" = "ключи предпочтительных компонентов слотов не удалось сопоставить со слоями файла, они пропущены" @@ -1594,6 +1666,7 @@ name = "Русский" "set-variable-mode actions on collections or modes that aren't in the variables JSON were skipped" = "действия смены режима переменных для коллекций или режимов, которых нет в JSON переменных, пропущены" "slot content assignment target is missing or is not slot content" = "цель назначения содержимого слота отсутствует или не является содержимым слота" "slot content assignment uses the empty GUID sentinel and was skipped" = "назначение содержимого слота использует пустой GUID-маркер и пропущено" +"slot layers without a matching slot property were imported as frames" = "слои слотов без подходящего свойства слота импортированы как фреймы" "some vector points refer to point settings missing from the file; they use the layer's joins and plain corners" = "некоторые векторные точки ссылаются на настройки точек, которых нет в файле; они используют соединения слоя и обычные углы" "some vector segments refer to style settings missing from the file; they use the layer's stroke" = "некоторые векторные сегменты ссылаются на настройки стиля, которых нет в файле; они используют обводку слоя" "stale or invalid grid track sizing entries were ignored" = "устаревшие или недопустимые записи размеров треков сетки проигнорированы" @@ -1635,6 +1708,7 @@ name = "Русский" "{name} is forgotten on this computer; the file stays on {server}" = "{name} забыт на этом компьютере; файл остаётся на {server}" "{name} is on the server." = "{name} находится на сервере." "{name} is spotlighting" = "{name} показывает экран всем" +"{name} is too large to open in the browser (over 512 MB)." = "Файл {name} слишком велик для открытия в браузере (больше 512 МБ)." "{name}\nClick to apply to the selection" = "{name}\nНажмите, чтобы применить к выделению" "{name}\nDrag onto the canvas, or click to place in the middle of the view.\nHold Alt (Option) while dropping onto an instance to swap it." = "{name}\nПеретащите на холст или нажмите, чтобы разместить в центре вида.\nУдерживайте Alt (Option) при переносе на экземпляр, чтобы заменить его." "{name} (from {library})" = "{name} (из {library})" @@ -1679,6 +1753,7 @@ name = "Русский" "{n} replies" = { one = "{n} ответ", few = "{n} ответа", many = "{n} ответов", other = "{n} ответа" } "{n} rows" = { one = "{n} строка", few = "{n} строки", many = "{n} строк", other = "{n} строки" } "{n} styles" = { one = "{n} стиль", few = "{n} стиля", many = "{n} стилей", other = "{n} стиля" } +"{n} text layers" = { one = "{n} текстовый слой", few = "{n} текстовых слоя", many = "{n} текстовых слоёв", other = "{n} текстовых слоя" } "{n} variables" = { one = "{n} переменная", few = "{n} переменные", many = "{n} переменных", other = "{n} переменной" } "{n} weeks ago" = { one = "{n} неделю назад", few = "{n} недели назад", many = "{n} недель назад", other = "{n} недели назад" } "{n} years ago" = { one = "{n} год назад", few = "{n} года назад", many = "{n} лет назад", other = "{n} года назад" } @@ -1687,6 +1762,7 @@ name = "Русский" "{n}m" = "{n} мин" "{n}w" = "{n} нед" "{page}, nothing selected" = "{page}, ничего не выделено" +"{percent}%" = "{percent}%" "{profile} (current)" = "{profile} (текущий)" "{property} is its value; {property ? \"a\" : \"b\"} picks a string. Other {…} stays as written." = "{property} — значение свойства; {property ? \"a\" : \"b\"} выбирает строку. Другие {…} остаются как есть." "{server} can't say what became of your files (it is older than this app)." = "{server} не может сообщить, что стало с вашими файлами (версия сервера старее приложения)." @@ -1729,9 +1805,19 @@ name = "Русский" "arrange.front" = "На передний план" "arrange.packH" = "Уплотнить по горизонтали" "arrange.packV" = "Уплотнить по вертикали" +"arrange.reorder" = "Изменить порядок выделения" "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" = "Пересечь выделение" @@ -1763,6 +1849,7 @@ name = "Русский" "collab.publish" = "Опубликовать на сервере" "collab.recoverFile" = "Восстановить онлайн-файл" "collab.registerLinks" = "Открывать ссылки приглашения в FigCraft" +"collab.removePerson" = "Убрать из сессии" "collab.report" = "Пожаловаться на файл оператору сервера" "collab.serverInfo" = "Информация о сервере" "collab.share" = "Поделиться по коду подключения" @@ -1855,7 +1942,9 @@ name = "Русский" "file.exportLayers" = "Экспортировать слои" "file.exportSvg" = "Экспортировать выделение в SVG" "file.import" = "Импорт" +"file.moveTab" = "Переместить вкладку" "file.new" = "Новый файл дизайна" +"file.newBoard" = "Новая доска" "file.newFromTemplate" = "Создать из шаблона" "file.open" = "Открыть" "file.openRecent" = "Открыть недавние" @@ -1940,6 +2029,7 @@ name = "Русский" "object.batchRename" = "Массовое переименование" "object.convertToFrame" = "Преобразовать во Frame" "object.convertToGroup" = "Преобразовать в группу" +"object.convertToSection" = "Преобразовать в секцию" "object.flipH" = "Отразить по горизонтали" "object.flipV" = "Отразить по вертикали" "object.frameSelection" = "Поместить выделение во Frame" @@ -1986,6 +2076,7 @@ name = "Русский" "plugin.ui" = "Панель плагина" "plugins.manage" = "Управление плагинами…" "plugins.runLast" = "Запустить последний плагин" +"pref.autoSave" = "Автоматически сохранять файлы" "pref.highlightHover" = "Подсвечивать слои при наведении" "pref.keepTool" = "Оставлять инструмент выбранным после использования" "pref.language" = "Язык интерфейса" @@ -1995,6 +2086,8 @@ name = "Русский" "pref.reduceMotion" = "Уменьшить движение" "pref.renameDuplicates" = "Переименовывать дубликаты слоёв" "pref.showDimensions" = "Показать размеры объектов" +"pref.startOnHome" = "Начинать с главной" +"pref.uiScale" = "Размер интерфейса" "pref.userName" = "Ваше имя в комментариях" "proto.action.add" = "Добавить действие прототипа" "proto.action.move" = "Изменить порядок действия прототипа" @@ -2100,14 +2193,18 @@ name = "Русский" "text.listIndent" = "Увеличить отступ списка" "text.listOutdent" = "Уменьшить отступ списка" "text.listStyle" = "Стиль списка" +"text.loadFont" = "Загрузить файл шрифта" "text.maxLines" = "Максимум строк" +"text.missingFonts" = "Отсутствующие шрифты" "text.numberList" = "Нумерованный список" "text.onPath" = "Текст на пути" "text.paragraphIndent" = "Абзацный отступ" "text.paragraphSpacing" = "Интервал между абзацами" "text.pathOptions" = "Параметры текста на пути" "text.preview" = "Предпросмотр изменения текста" +"text.refit" = "Подогнать текст под шрифты" "text.removeFromPath" = "Снять текст с пути" +"text.rescanFonts" = "Повторно найти установленные шрифты" "text.set" = "Задать текст" "text.setFeatures" = "Функции OpenType" "text.setVariations" = "Оси вариативного шрифта" @@ -2122,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" = "Карандаш" @@ -2137,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" = "Переместить в" @@ -2201,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 d57bb25a..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" @@ -315,6 +318,7 @@ name = "Svenska" "Corner smoothing" = "Hörnutjämning" "Couldn't download the variables" = "Det gick inte att hämta variablerna" "Couldn't reach {server}: showing what this computer remembers." = "Det gick inte att nå {server}: visar det som den här datorn minns." +"Couldn't read {name}." = "Det gick inte att läsa {name}." "Couldn't save automatically: {error}" = "Det gick inte att spara automatiskt: {error}" "Count" = "Antal" "Create" = "Skapa" @@ -373,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" @@ -400,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" @@ -435,6 +441,7 @@ name = "Svenska" "Effect settings" = "Effektinställningar" "Effect styles" = "Effektstilar" "Effects" = "Effekter" +"Elbow" = "Vinklad" "Ellipse" = "Ellips" "Ellipses" = "Ellipser" "Else" = "Annars" @@ -571,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" @@ -583,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" @@ -749,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)" @@ -807,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" @@ -908,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" @@ -942,8 +954,10 @@ name = "Svenska" "PDF print settings" = "PDF-utskriftsinställningar" "Padding" = "Utfyllnad" "Page" = "Sida" +"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" @@ -986,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" @@ -1034,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" @@ -1134,6 +1150,7 @@ name = "Svenska" "Rotation jitter" = "Rotationsdarr" "Round" = "Rund" "Rounded" = "Rundad" +"Rounded rectangle" = "Rundad rektangel" "Row" = "Rad" "Row gap" = "Radmellanrum" "Row sizes" = "Radstorlekar" @@ -1207,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" @@ -1273,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" @@ -1286,18 +1307,23 @@ name = "Svenska" "Start from a blank canvas" = "Börja med en tom arbetsyta" "Start from a template" = "Börja med en mall" "Start on path" = "Början på banan" +"Start on the Home screen" = "Börja på Start" "Start point" = "Startpunkt" "Start sharing" = "Börja dela" "Start sharing with the Share button, then pass on the code it gives" = "Börja dela med knappen Dela och skicka sedan vidare koden du får" "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" @@ -1342,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" @@ -1375,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" @@ -1391,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" @@ -1415,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" @@ -1650,6 +1679,7 @@ name = "Svenska" "{name} is forgotten on this computer; the file stays on {server}" = "{name} har glömts på den här datorn; filen finns kvar på {server}" "{name} is on the server." = "{name} finns på servern." "{name} is spotlighting" = "{name} har spotlight" +"{name} is too large to open in the browser (over 512 MB)." = "{name} är för stor för att öppnas i webbläsaren (över 512 MB)." "{name}\nClick to apply to the selection" = "{name}\nKlicka för att använda på markeringen" "{name}\nDrag onto the canvas, or click to place in the middle of the view.\nHold Alt (Option) while dropping onto an instance to swap it." = "{name}\nDra till arbetsytan, eller klicka för att placera i mitten av vyn.\nHåll ned Alt (Option) när du släpper på en instans för att byta ut den." "{name} (from {library})" = "{name} (från {library})" @@ -1748,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" @@ -1872,7 +1911,9 @@ name = "Svenska" "file.exportLayers" = "Exportera lager" "file.exportSvg" = "Exportera som SVG" "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" @@ -2013,6 +2054,7 @@ name = "Svenska" "pref.reduceMotion" = "Minska rörelse" "pref.renameDuplicates" = "Byt namn på duplicerade lager" "pref.showDimensions" = "Visa dimensioner på objekt" +"pref.startOnHome" = "Börja på Start" "pref.userName" = "Ditt namn i kommentarer" "proto.action.add" = "Lägg till prototypåtgärd" "proto.action.move" = "Ändra ordning på prototypåtgärd" @@ -2141,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" @@ -2156,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" @@ -2220,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 a9da62ef..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" @@ -314,6 +317,7 @@ name = "Türkçe" "Corner radius" = "Köşe yarıçapı" "Corner smoothing" = "Köşe yumuşatma" "Couldn't reach {server}: showing what this computer remembers." = "{server} sunucusuna ulaşılamadı: bu bilgisayarın hatırladıkları gösteriliyor." +"Couldn't read {name}." = "{name} okunamadı." "Count" = "Sayı" "Create" = "Oluştur" "Create a variable collection first" = "Önce bir değişken koleksiyonu oluşturun" @@ -371,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" @@ -398,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" @@ -430,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" @@ -565,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" @@ -577,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" @@ -742,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ı)" @@ -800,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" @@ -901,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ç" @@ -933,8 +945,10 @@ name = "Türkçe" "PDF print settings" = "PDF baskı ayarları" "Padding" = "İç boşluk" "Page" = "Sayfa" +"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ı" @@ -977,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" @@ -1025,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" @@ -1124,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ı" @@ -1196,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ş" @@ -1262,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" @@ -1281,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" @@ -1331,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" @@ -1361,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ü" @@ -1377,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" @@ -1401,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ı" @@ -1633,6 +1661,7 @@ name = "Türkçe" "{name} is forgotten on this computer; the file stays on {server}" = "{name} bu bilgisayarda unutuldu; dosya {server} üzerinde kalır" "{name} is on the server." = "{name} sunucuda." "{name} is spotlighting" = "{name} öne çıkarıyor" +"{name} is too large to open in the browser (over 512 MB)." = "{name}, tarayıcıda açılamayacak kadar büyük (512 MB'tan fazla)." "{name}\nClick to apply to the selection" = "{name}\nSeçime uygulamak için tıklayın" "{name}\nDrag onto the canvas, or click to place in the middle of the view.\nHold Alt (Option) while dropping onto an instance to swap it." = "{name}\nTuvale sürükleyin veya görünümün ortasına yerleştirmek için tıklayın.\nBir örneği değiştirmek için üzerine bırakırken Alt (Option) tuşunu basılı tutun." "{name} (from {library})" = "{name} ({library} kitaplığından)" @@ -1730,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" @@ -1853,7 +1891,9 @@ name = "Türkçe" "file.exportLayers" = "Katmanları dışa aktar" "file.exportSvg" = "Seçimi SVG olarak dışa aktar" "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ç" @@ -2120,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" @@ -2135,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şı" @@ -2199,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 a90c1c03..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" = "内容" @@ -306,6 +310,7 @@ status = "draft — needs native review" "Corner radius" = "圆角半径" "Corner smoothing" = "圆角平滑" "Couldn't reach {server}: showing what this computer remembers." = "无法连接 {server}:显示的是这台电脑记录的内容。" +"Couldn't read {name}." = "无法读取 {name}。" "Couldn't save automatically: {error}" = "无法自动保存:{error}" "Count" = "数量" "Create" = "创建" @@ -360,6 +365,7 @@ status = "draft — needs native review" "Description" = "描述" "Description (optional)" = "描述(可选)" "Design" = "设计" +"Design panel" = "设计面板" "Desktop" = "桌面" "Destination" = "目标" "Detach alias" = "分离别名" @@ -370,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" = "设备" @@ -386,6 +393,7 @@ status = "draft — needs native review" "Distribute horizontal spacing" = "水平间距分布" "Distribute vertical spacing" = "垂直间距分布" "Divider" = "分隔线" +"Don't Save" = "不保存" "Don't truncate" = "不截断" "Done" = "完成" "Done editing" = "完成编辑" @@ -416,6 +424,7 @@ status = "draft — needs native review" "Effect settings" = "效果设置" "Effect styles" = "效果样式" "Effects" = "效果" +"Elbow" = "折线" "Ellipse" = "椭圆" "Ellipses" = "椭圆" "Else" = "否则" @@ -546,6 +555,7 @@ status = "draft — needs native review" "Hanging list" = "悬挂列表" "Hanging punctuation" = "标点悬挂" "Hard light" = "强光" +"Heart" = "爱心" "Height" = "高度" "Help" = "帮助" "Help and resources" = "帮助和资源" @@ -556,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" @@ -711,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)" = "匹配(留空:整个名称)" @@ -767,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" = "聚焦区域中的下一个或上一个控件" @@ -829,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." = "还没有在线文件。对文件使用“分享”将其放到网上后,它会列在这里。" @@ -862,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 中打开邀请链接" @@ -893,8 +908,10 @@ status = "draft — needs native review" "PDF print settings" = "PDF 打印设置" "Padding" = "内边距" "Page" = "页面" +"Page deleted" = "页面已删除" "Pages" = "页面" "Pages added to the document" = "页面已添加到文档" +"Pink" = "粉色" "Placed on the current page" = "已放置在当前页面" "Pages: {pages}" = "页面:{pages}" "Paint bucket" = "油漆桶" @@ -936,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" = "多边形" @@ -982,6 +1000,7 @@ status = "draft — needs native review" "Push" = "推入" "Push changes to main component" = "将更改推送到主组件" "Put text on path" = "将文本放到路径上" +"Question" = "问号" "R" = "R" "Radial" = "径向" "Radius" = "半径" @@ -1026,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" = "重命名文件夹" @@ -1081,6 +1101,7 @@ status = "draft — needs native review" "Rotation jitter" = "旋转抖动" "Round" = "圆形" "Rounded" = "圆润" +"Rounded rectangle" = "圆角矩形" "Row" = "行" "Row gap" = "行间距" "Row sizes" = "行高" @@ -1150,6 +1171,7 @@ status = "draft — needs native review" "Shape" = "形状" "Shape builder" = "形状生成器" "Shape tools" = "形状工具" +"Shapes" = "形状" "Share" = "分享" "Share this file" = "分享此文件" "Share: live session, export" = "分享:实时会话、导出" @@ -1207,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" = "星形" @@ -1219,17 +1244,22 @@ status = "draft — needs native review" "Start ascending from / stop descending at" = "升序起点 / 降序终点" "Start from a blank canvas" = "从空白画布开始" "Start on path" = "路径起点" +"Start on the Home screen" = "启动时显示主页" "Start point" = "起点" "Start sharing" = "开始分享" "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" = "字符串" @@ -1273,6 +1303,7 @@ status = "draft — needs native review" "Text" = "文本" "Text align" = "文本对齐" "Text content" = "文本内容" +"Text size" = "文字大小" "Text style" = "文本样式" "Text styles" = "文本样式" "Texts" = "文本" @@ -1303,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" = "平铺类型" @@ -1319,6 +1353,7 @@ status = "draft — needs native review" "Top left radius" = "左上圆角" "Top right radius" = "右上圆角" "Transform" = "变换" +"Triangle" = "三角形" "Triangle arrow" = "三角箭头" "Trigger" = "触发器" "True" = "真" @@ -1341,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" = "向上" @@ -1404,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" = "白" @@ -1568,6 +1605,7 @@ status = "draft — needs native review" "{name} is forgotten on this computer; the file stays on {server}" = "已在这台电脑上忘记 {name};文件仍保留在 {server} 上" "{name} is on the server." = "{name} 已在服务器上。" "{name} is spotlighting" = "{name} 正在聚焦" +"{name} is too large to open in the browser (over 512 MB)." = "{name} 太大,无法在浏览器中打开(超过 512 MB)。" "{name}\nClick to apply to the selection" = "{name}\n点击应用于所选项" "{name}\nDrag onto the canvas, or click to place in the middle of the view.\nHold Alt (Option) while dropping onto an instance to swap it." = "{name}\n拖到画布上,或点击放置在视图中央。\n拖放到实例上时按住 Alt(Option)可替换该实例。" "{name} (from {library})" = "{name}(来自 {library})" @@ -1665,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" = "所选项相交" @@ -1787,7 +1834,9 @@ status = "draft — needs native review" "file.exportLayers" = "导出图层" "file.exportSvg" = "将所选项导出为 SVG" "file.import" = "导入" +"file.moveTab" = "移动标签页" "file.new" = "新建设计文件" +"file.newBoard" = "新建白板" "file.open" = "打开" "file.openRecent" = "打开最近的文件" "file.placeImage" = "置入图片" @@ -1924,6 +1973,7 @@ status = "draft — needs native review" "pref.reduceMotion" = "减少动态效果" "pref.renameDuplicates" = "重命名副本图层" "pref.showDimensions" = "在对象上显示尺寸" +"pref.startOnHome" = "启动时显示主页" "pref.userName" = "评论中显示的名字" "proto.action.add" = "添加原型操作" "proto.action.move" = "调整原型操作顺序" @@ -2050,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" = "铅笔" @@ -2065,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" = "移动到" @@ -2129,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/assets_panel.rs b/crates/ui-egui/src/assets_panel.rs index f8ea18e0..36636c3d 100644 --- a/crates/ui-egui/src/assets_panel.rs +++ b/crates/ui-egui/src/assets_panel.rs @@ -316,7 +316,7 @@ fn search_row(app: &mut FigApp, ui: &mut Ui, w: f32) -> String { ); crate::widgets::select_all_on_focus(ui, &r, &filter); if r.has_focus() { - ui.painter().rect_stroke(field, CornerRadius::same(5), Stroke::new(1.0, t.accent), egui::StrokeKind::Inside); + ui.painter().rect_stroke(field, CornerRadius::same(5), Stroke::new(1.0, crate::theme::ACCENT), egui::StrokeKind::Inside); if ui.input(|i| i.key_pressed(egui::Key::Escape)) { filter.clear(); } diff --git a/crates/ui-egui/src/async_dialog_tests.rs b/crates/ui-egui/src/async_dialog_tests.rs index 26808fe5..f38ac824 100644 --- a/crates/ui-egui/src/async_dialog_tests.rs +++ b/crates/ui-egui/src/async_dialog_tests.rs @@ -2,7 +2,7 @@ use std::path::PathBuf; use figcraft_engine::Session; -use crate::{DialogFuture, FigApp, Services}; +use crate::{ContentsFuture, DialogFuture, FigApp, Services}; struct OpenFile(PathBuf); @@ -79,6 +79,74 @@ fn save_as_saves_the_file_it_was_opened_for_after_another_tab_comes_to_the_front let _ = std::fs::remove_dir_all(dir); } +/// The browser: the picker hands over a file's name and contents, never a path. +struct Contents(&'static str, Vec); + +impl Services for Contents { + fn pick_open(&mut self, _title: &str, _exts: &[&str]) -> DialogFuture { + panic!("a platform that picks contents is never asked for a path") + } + fn pick_open_contents(&mut self, _title: &str, _exts: &[&str]) -> Option { + Some(Box::pin(std::future::ready(Some(Ok((self.0.to_string(), self.1.clone())))))) + } +} + +/// The browser, with a picked file it won't read. +struct Unreadable(String); + +impl Services for Unreadable { + fn pick_open_contents(&mut self, _title: &str, _exts: &[&str]) -> Option { + Some(Box::pin(std::future::ready(Some(Err(self.0.clone()))))) + } +} + +#[test] +fn a_picked_file_that_cannot_be_read_says_why_and_opens_nothing() { + let why = crate::browser_can_read("Huge.fig", 600.0 * 1024.0 * 1024.0).unwrap_err(); + let mut app = FigApp::new(Session::empty(), Box::new(Unreadable(why.clone()))); + app.open_dialog(); + app.poll_dialog(&egui::Context::default()); + assert!(app.session.docs.is_empty()); + assert_eq!(app.ui.last_error.as_deref(), Some(why.as_str())); + assert!(why.contains("Huge.fig"), "{why}"); +} + +#[test] +fn the_browser_reads_files_up_to_512_mib() { + let max = (512u64 << 20) as f64; + assert!(crate::browser_can_read("a", 0.0).is_ok()); + assert!(crate::browser_can_read("a", max).is_ok()); + assert!(crate::browser_can_read("a", max + 1.0).is_err()); + assert!(crate::browser_can_read("a", f64::NAN).is_err()); + assert!(crate::browser_can_read("a", f64::INFINITY).is_err()); +} + +#[test] +fn open_dialog_opens_picked_contents_as_a_new_unsaved_document() { + let bytes = figcraft_format::save_bytes(&figcraft_doc::Document::new("Picked"), None).unwrap(); + let mut app = FigApp::new(Session::empty(), Box::new(Contents("Picked.figcraft", bytes))); + app.ui.home = true; + app.open_dialog(); + assert!(app.session.docs.is_empty(), "opening waits for the picker result"); + app.poll_dialog(&egui::Context::default()); + assert_eq!(app.session.docs.len(), 1); + assert_eq!(app.session.docs[0].path, None, "no path to save over"); + assert_eq!(app.session.docs[0].doc.name, "Picked"); + assert!(!app.ui.home, "and the editor shows it"); +} + +#[test] +fn import_dialog_imports_picked_contents_into_the_file_in_front() { + let svg = br#""#.to_vec(); + let mut app = FigApp::new(Session::new(), Box::new(Contents("Logo.svg", svg))); + let rectangles = |app: &FigApp| app.session.doc().unwrap().nodes().filter(|n| matches!(n.kind, figcraft_doc::NodeKind::Rectangle)).count(); + assert_eq!(rectangles(&app), 0); + app.import_dialog(false); + app.poll_dialog(&egui::Context::default()); + assert_eq!(app.session.docs.len(), 1, "imported, not opened"); + assert_eq!(rectangles(&app), 1, "the SVG's content"); +} + #[test] fn a_dialog_whose_file_closed_meanwhile_does_nothing() { let (mut app, dir, path) = two_files("closed"); 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 9e0c1d79..0b13f5a8 100644 --- a/crates/ui-egui/src/canvas.rs +++ b/crates/ui-egui/src/canvas.rs @@ -12,7 +12,7 @@ use serde_json::json; use crate::FigApp; use crate::i18n::{tr, trf, trn}; use crate::render_worker::{Done, Job, Worker}; -use crate::theme::{ACCENT, COMPONENT, Tokens, doc_c32, regular, semibold}; +use crate::theme::{COMPONENT, Tokens, doc_c32, regular, semibold}; pub struct CanvasState { pub rect: Rect, @@ -183,7 +183,9 @@ pub fn show(app: &mut FigApp, ui: &mut Ui, worker: &mut Worker) { } } let own_cursor = input(app, ui, &resp); - crate::drop_overlay(&ctx, ctx.content_rect(), tr("Drop to import")); + // A browser names no dragged file until the drop, so documents there still say "import". + let documents = ctx.input(|i| i.raw.hovered_files.iter().all(|f| f.path.as_deref().is_some_and(crate::is_document))); + crate::drop_overlay(&ctx, ctx.content_rect(), if documents { tr("Drop to open") } else { tr("Drop to import") }); crate::guides::end_released(&ctx); crate::live::track_pointer(app, &ctx); app.sync_preedit(); @@ -233,6 +235,7 @@ pub fn show(app: &mut FigApp, ui: &mut Ui, worker: &mut Worker) { }) .unwrap_or(t.canvas); painter.rect_filled(rect, CornerRadius::ZERO, bg); + crate::theme::set_canvas_page(&ctx, bg); if let (Some(tex), Some(v), true) = (&app.canvas.texture, &app.canvas.tex_view, shown.is_some() && app.canvas.tex_of == shown) { // Re-project the last render onto the current view. let tl = app.to_screen(v.origin.0, v.origin.1); @@ -247,12 +250,14 @@ pub fn show(app: &mut FigApp, ui: &mut Ui, worker: &mut Worker) { if let (Some(start), Some(p)) = (app.canvas.zoom_drag, ctx.input(|i| i.pointer.hover_pos())) { let area = Rect::from_two_pos(start, p); if area.width() > 4.0 || area.height() > 4.0 { - painter.rect_filled(area, CornerRadius::ZERO, ACCENT.gamma_multiply(0.1)); - painter.rect_stroke(area, CornerRadius::ZERO, Stroke::new(1.0, ACCENT), StrokeKind::Inside); + painter.rect_filled(area, CornerRadius::ZERO, accent(&painter).gamma_multiply(0.1)); + painter.rect_stroke(area, CornerRadius::ZERO, Stroke::new(1.0, accent(&painter)), StrokeKind::Inside); } } 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); @@ -490,6 +495,8 @@ fn zoom_tool(app: &mut FigApp, ui: &Ui, resp: &egui::Response, pos: Pos2, mods: /// then doesn't show. fn input(app: &mut FigApp, ui: &mut Ui, resp: &egui::Response) -> bool { if crate::proto_canvas::input(app, ui, resp) { + // A prototype gesture is a press on the canvas too: the Pages list gives up the keyboard. + app.ui.pages_focus = None; return true; } let ctx = ui.ctx().clone(); @@ -501,27 +508,28 @@ fn input(app: &mut FigApp, ui: &mut Ui, resp: &egui::Response) -> bool { if !app.canvas.dragging && crate::selection_spacing::input(app, &ctx, hover_pos, resp.hovered(), app.canvas.space_pan) { return false; } - // Dropped files import onto the canvas, behind a card while they do ([`crate::pending_open`]). + // Dropped files import onto the canvas, behind a card while they do ([`crate::pending_open`]); + // a dropped document opens as a file of its own, as on the home screen. let dropped = ctx.input(|i| i.raw.dropped_files.clone()); for f in dropped { let path = f.path().to_path_buf(); + if crate::is_document(&path) { + crate::pending_open::open_dropped(app, &f); + continue; + } let pt = hover_pos.map(|h| app.to_page(h)).unwrap_or_default(); let name = path.file_name().map(|n| n.to_string_lossy().to_string()).unwrap_or_else(|| "dropped.png".into()); - let params = if path.exists() { - json!({"path": path.to_string_lossy(), "x": pt.x, "y": pt.y}) - } else if let Some(bytes) = crate::dropped_bytes(&f) { - use base64::Engine as _; - let b64 = base64::engine::general_purpose::STANDARD.encode(&bytes); - json!({"data": b64, "name": name, "x": pt.x, "y": pt.y}) + let (params, read) = if path.exists() { + (json!({"path": path.to_string_lossy(), "x": pt.x, "y": pt.y}), None) } else { - continue; + (json!({"name": name, "x": pt.x, "y": pt.y}), Some(crate::dropped_bytes(&f, &name))) }; let Some(target) = app.session.ds().ok().map(|doc| crate::pending_open::ImportTarget { uid: doc.uid, page: doc.page, name: doc.doc.name.clone() }) else { continue; }; - app.ui.pending_opens.push(crate::pending_open::PendingOpen { label: name, params, command: "file.import", target: Some(target) }); + app.ui.pending_opens.push(crate::pending_open::PendingOpen { label: name, params, command: "file.import", target: Some(target), read }); } let Some(pos) = hover_pos.filter(|p| app.canvas.rect.contains(*p)) else { // A drag released off the canvas, or off the window, ends where the pointer was last seen. @@ -544,6 +552,7 @@ fn input(app: &mut FigApp, ui: &mut Ui, resp: &egui::Response) -> bool { // A press on the canvas gives the keyboard back to it (arrows nudge again). if resp.hovered() && ctx.input(|i| i.pointer.any_pressed()) { app.ui.layers_focus = false; + app.ui.pages_focus = None; app.ui.key_box = None; } // Zoom (pinch, Cmd/Ctrl+scroll) about the pointer; plain scroll pans. @@ -809,11 +818,11 @@ fn swap_highlight(app: &FigApp, painter: &egui::Painter, doc: &Document, id: Nod } /// The outline colour for a layer: purple for components, sets and instances and every layer in -/// them, blue otherwise. -fn outline_colour(doc: &Document, id: NodeId) -> Color32 { +/// them, the page's overlay `green` otherwise. +fn outline_colour(doc: &Document, id: NodeId, green: Color32) -> Color32 { use figcraft_engine::components::{owning_component, owning_instance}; let set = matches!(doc.node(id).map(|n| &n.kind), Some(NodeKind::ComponentSet { .. })); - if set || owning_component(doc, id).is_some() || owning_instance(doc, id).is_some() { COMPONENT } else { ACCENT } + if set || owning_component(doc, id).is_some() || owning_instance(doc, id).is_some() { COMPONENT } else { green } } /// The outline path of shapes (ellipses, vectors) for hover/selection outlines. @@ -932,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)))); } @@ -946,20 +956,20 @@ fn overlays(app: &FigApp, painter: &egui::Painter, t: &Tokens) -> Vec<(NodeId, [ && app.session.prefs.highlight_on_hover && app.session.tool.overlay.measure_box.is_none() { - draw_outline(app, painter, &doc, h, stroke(outline_colour(&doc, h), 1.5)); + draw_outline(app, painter, &doc, h, stroke(outline_colour(&doc, h, accent(painter)), 1.5)); } // Inside a boolean group (an operand selected or in vector edit mode): every operand's outline, // so the shapes making the live result stay visible while one of them changes. let operands = figcraft_engine::booleans::outlined_operands(&doc, &sel, ds.vector_edit.as_ref().map(|ve| ve.id)); for id in operands.iter().filter(|o| !sel.contains(o)) { - draw_outline(app, painter, &doc, *id, stroke(outline_colour(&doc, *id).gamma_multiply(0.6), 1.0)); + draw_outline(app, painter, &doc, *id, stroke(outline_colour(&doc, *id, accent(painter)).gamma_multiply(0.6), 1.0)); } // Mask outlines (a mask doesn't draw itself), frame outlines and slices, as the View menu shows. if view.mask_outlines || view.frame_outlines || view.show_slices { for id in doc.subtree(page) { let Some(n) = doc.node(id).filter(|n| n.visible) else { continue }; if view.mask_outlines && n.is_mask { - draw_outline(app, painter, &doc, id, stroke(ACCENT, 1.0)); + draw_outline(app, painter, &doc, id, stroke(accent(painter), 1.0)); } else if view.frame_outlines && n.kind.is_frame_like() { draw_outline(app, painter, &doc, id, stroke(t.text_secondary, 1.0)); } else if view.show_slices @@ -975,20 +985,20 @@ fn overlays(app: &FigApp, painter: &egui::Painter, t: &Tokens) -> Vec<(NodeId, [ if let Some(d) = ov.drop_target && let Some(q) = node_quad(app, &doc, d) { - quad(painter, q, stroke(if figcraft_engine::components::is_slot(&doc, d) { COMPONENT } else { ACCENT }, 1.0)); + quad(painter, q, stroke(if figcraft_engine::components::is_slot(&doc, d) { COMPONENT } else { accent(painter) }, 1.0)); } slot_badges(app, painter, &doc, &sel, ov.drop_target); if let Some(t) = ov.swap_target { swap_highlight(app, painter, &doc, t); } if let Some([x0, y0, x1, y1]) = ov.insert_line { - painter.line_segment([app.to_screen(x0, y0), app.to_screen(x1, y1)], stroke(ACCENT, 2.0)); + painter.line_segment([app.to_screen(x0, y0), app.to_screen(x1, y1)], stroke(accent(painter), 2.0)); } if let Some([x0, y0, x1, y1]) = ov.drop_cell { let r = Rect::from_two_pos(app.to_screen(x0, y0), app.to_screen(x1, y1)); - painter.rect_filled(r, CornerRadius::same(2), ACCENT.gamma_multiply(0.12)); + painter.rect_filled(r, CornerRadius::same(2), accent(painter).gamma_multiply(0.12)); let pts = [r.left_top(), r.right_top(), r.right_bottom(), r.left_bottom(), r.left_top()]; - painter.add(Shape::dashed_line(&pts, stroke(ACCENT, 1.5), 4.0, 3.0)); + painter.add(Shape::dashed_line(&pts, stroke(accent(painter), 1.5), 4.0, 3.0)); } // Vector edit mode, with the transform box around several selected points. // The selection's size label, which measurement labels keep clear of. @@ -1011,13 +1021,13 @@ fn overlays(app: &FigApp, painter: &egui::Painter, t: &Tokens) -> Vec<(NodeId, [ width_overlay(app, painter, &doc, &ve, zoom); } else if let Some(c) = app.session.vector_edit_box() { let label = format!("{} × {}", fmt((c[1] - c[0]).hypot()), fmt((c[3] - c[0]).hypot())); - size_label = transform_box(app, painter, c.map(|p| app.to_screen(p.x, p.y)), ACCENT, true, 4, label); + size_label = transform_box(app, painter, c.map(|p| app.to_screen(p.x, p.y)), accent(painter), true, 4, Some(label)); } } // Selection. let editing = ds.text_edit.clone(); if !sel.is_empty() && editing.is_none() && ds.vector_edit.is_none() { - let col = if sel.iter().any(|i| outline_colour(&doc, *i) == COMPONENT) { COMPONENT } else { ACCENT }; + let col = if sel.iter().any(|i| outline_colour(&doc, *i, accent(painter)) == COMPONENT) { COMPONENT } else { accent(painter) }; for id in &sel { draw_outline(app, painter, &doc, *id, stroke(col, 1.0)); } @@ -1035,8 +1045,11 @@ fn overlays(app: &FigApp, painter: &egui::Painter, t: &Tokens) -> Vec<(NodeId, [ // A layer smaller than its handles on screen shows just its outline (they still answer // just outside it). let tiny = figcraft_engine::tools::box_sides_px(&b, zoom).1 < figcraft_engine::tools::SMALL_BOX_PX; - // Locked layers (selected in the layers panel) can't be resized: no handles either. - let handles = if sel.iter().any(|i| figcraft_engine::tools::locked_in(&doc, *i)) { + // 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, 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 @@ -1045,9 +1058,11 @@ fn overlays(app: &FigApp, painter: &egui::Painter, t: &Tokens) -> Vec<(NodeId, [ } else { 4 }; - size_label = transform_box(app, painter, b.map(|p| app.to_screen(p.x, p.y)), col, sel.len() > 1, handles, label); - arc_overlay(app, painter, col); - shape_overlay(app, painter, col); + size_label = transform_box(app, painter, b.map(|p| app.to_screen(p.x, p.y)), col, sel.len() > 1, handles, (!measuring).then_some(label)); + if !measuring { + arc_overlay(app, painter, col); + shape_overlay(app, painter, col); + } if app.session.tool.tool == ToolId::Scale { // The point that stays put: the dragged pivot, else the anchor. let pivot = scale @@ -1086,12 +1101,12 @@ fn overlays(app: &FigApp, painter: &egui::Painter, t: &Tokens) -> Vec<(NodeId, [ { let layout = &text.layout; if let Some(q) = node_quad(app, edoc, te.id) { - quad(painter, q, stroke(ACCENT, 1.0)); + quad(painter, q, stroke(accent(painter), 1.0)); } let (a, b) = (te.caret.min(te.anchor), te.caret.max(te.anchor)); if a != b && preedit.is_none() { for q in text.selection(app, a, b) { - painter.add(Shape::convex_polygon(q.to_vec(), ACCENT.gamma_multiply(0.3), Stroke::NONE)); + painter.add(Shape::convex_polygon(q.to_vec(), accent(painter).gamma_multiply(0.3), Stroke::NONE)); } } if let Some(p) = preedit { @@ -1122,8 +1137,8 @@ fn overlays(app: &FigApp, painter: &egui::Painter, t: &Tokens) -> Vec<(NodeId, [ // Marquee. if let Some([x0, y0, x1, y1]) = ov.marquee.or(app.ui.key_box) { let r = Rect::from_two_pos(app.to_screen(x0, y0), app.to_screen(x1, y1)); - painter.rect_filled(r, CornerRadius::ZERO, ACCENT.gamma_multiply(0.1)); - painter.rect_stroke(r, CornerRadius::ZERO, stroke(ACCENT, 1.0), StrokeKind::Inside); + painter.rect_filled(r, CornerRadius::ZERO, accent(painter).gamma_multiply(0.1)); + painter.rect_stroke(r, CornerRadius::ZERO, stroke(accent(painter), 1.0), StrokeKind::Inside); } // Pen path. if !ov.pen.is_empty() { @@ -1138,7 +1153,7 @@ fn overlays(app: &FigApp, painter: &egui::Painter, t: &Tokens) -> Vec<(NodeId, [ } for p in &pts { painter.circle_filled(*p, 3.5, Color32::WHITE); - painter.circle_stroke(*p, 3.5, stroke(ACCENT, 1.0)); + painter.circle_stroke(*p, 3.5, stroke(accent(painter), 1.0)); } if let Some([x0, y0, x1, y1]) = ov.rubber { let (a, b) = (app.to_screen(x0, y0), app.to_screen(x1, y1)); @@ -1150,11 +1165,11 @@ fn overlays(app: &FigApp, painter: &egui::Painter, t: &Tokens) -> Vec<(NodeId, [ [a, h, b, b], false, Color32::TRANSPARENT, - stroke(ACCENT, 1.0), + stroke(accent(painter), 1.0), )); } None => { - painter.line_segment([a, b], stroke(ACCENT, 1.0)); + painter.line_segment([a, b], stroke(accent(painter), 1.0)); } } } @@ -1165,7 +1180,7 @@ fn overlays(app: &FigApp, painter: &egui::Painter, t: &Tokens) -> Vec<(NodeId, [ let c = app.to_screen(x, y); let rs = (r * app.session.ds().map(|d| d.view.zoom).unwrap_or(1.0)) as f32; painter.circle_stroke(c, rs, stroke(Color32::WHITE, 3.0)); - painter.circle_stroke(c, rs, stroke(ACCENT, 1.5)); + painter.circle_stroke(c, rs, stroke(accent(painter), 1.5)); } // Lasso (vector edit mode). if ov.lasso.len() > 1 { @@ -1173,7 +1188,7 @@ fn overlays(app: &FigApp, painter: &egui::Painter, t: &Tokens) -> Vec<(NodeId, [ if let Some(first) = pts.first().copied() { pts.push(first); } - painter.add(Shape::dashed_line(&pts, stroke(ACCENT, 1.0), 4.0, 3.0)); + painter.add(Shape::dashed_line(&pts, stroke(accent(painter), 1.0), 4.0, 3.0)); } // Guides (under the rulers). crate::guides::draw(app, painter); @@ -1185,8 +1200,16 @@ fn overlays(app: &FigApp, painter: &egui::Painter, t: &Tokens) -> Vec<(NodeId, [ } /// A transform box (screen corners tl, tr, br, bl): its outline when `outline`, the first `handles` -/// corner handles and, when dimensions are shown, the size `label` under it (its box returned). -fn transform_box(app: &FigApp, painter: &egui::Painter, q: [Pos2; 4], col: Color32, outline: bool, handles: usize, label: String) -> Option { +/// corner handles and, when dimensions are shown, the size `label` (if any) under it (its box returned). +fn transform_box( + app: &FigApp, + painter: &egui::Painter, + q: [Pos2; 4], + col: Color32, + outline: bool, + handles: usize, + label: Option, +) -> Option { if outline { quad(painter, q, stroke(col, 1.0)); } @@ -1195,7 +1218,7 @@ fn transform_box(app: &FigApp, painter: &egui::Painter, q: [Pos2; 4], col: Color painter.rect_filled(r, CornerRadius::same(1), Color32::WHITE); painter.rect_stroke(r, CornerRadius::same(1), stroke(col, 1.0), StrokeKind::Inside); } - if app.session.prefs.show_dimensions { + if let Some(label) = label.filter(|_| app.session.prefs.show_dimensions) { let bottom = q.iter().map(|p| p.y).fold(f32::MIN, f32::max); let cx = q.iter().map(|p| p.x).sum::() / 4.0; return Some(pill(painter, pos2(cx, bottom + 9.0), Align2::CENTER_TOP, label, col)); @@ -1207,7 +1230,7 @@ fn transform_box(app: &FigApp, painter: &egui::Painter, q: [Pos2; 4], col: Color fn pill(painter: &egui::Painter, at: Pos2, align: Align2, text: String, col: Color32) -> Rect { let galley = painter.layout_no_wrap(text, semibold(11.0), Color32::WHITE); let r = align.anchor_size(at, galley.size() + vec2(8.0, 4.0)); - painter.rect_filled(r, CornerRadius::same(3), col); + painter.rect_filled(r, CornerRadius::same(3), crate::theme::under_white(col)); painter.galley(r.min + vec2(4.0, 2.0), galley, Color32::WHITE); r } @@ -1264,7 +1287,7 @@ fn width_overlay(app: &FigApp, painter: &egui::Painter, doc: &Document, ve: &fig let sp = |p: &[f64; 2]| app.to_screen(p[0], p[1]); let draw = |h: &figcraft_engine::vectoredit::WidthHandle, ghost: bool| { let (c, a, b) = (sp(&h.at), sp(&h.sides[0]), sp(&h.sides[1])); - let col = if ghost { ACCENT.gamma_multiply(0.5) } else { ACCENT }; + let col = if ghost { accent(painter).gamma_multiply(0.5) } else { accent(painter) }; painter.line_segment([a, b], stroke(Color32::from_white_alpha(160), 3.0)); painter.line_segment([a, b], stroke(col, 1.0)); for (e, side) in [(a, WidthSide::Right), (b, WidthSide::Left)] { @@ -1276,7 +1299,7 @@ fn width_overlay(app: &FigApp, painter: &egui::Painter, doc: &Document, ve: &fig let on = !ghost && ve.width_point == Some(h.index); let hovered = !ghost && hover == Some(WHit::Point { index: h.index }); let r = if hovered { 5.0 } else { 4.0 }; - painter.circle_filled(c, r, if on { ACCENT } else { Color32::WHITE }); + painter.circle_filled(c, r, if on { accent(painter) } else { Color32::WHITE }); painter.circle_stroke(c, r, stroke(col, 1.0)); }; for h in width_handles(doc, ve, zoom) { @@ -1292,9 +1315,9 @@ fn width_overlay(app: &FigApp, painter: &egui::Painter, doc: &Document, ve: &fig /// A bezier handle from its anchor `v` to its end `e` (screen points): the line, and a dot of /// radius `r` filled with `fill` (vector edit mode and the Pen draw them alike). fn bezier_handle(painter: &egui::Painter, v: Pos2, e: Pos2, r: f32, fill: Color32) { - painter.line_segment([v, e], stroke(ACCENT, 1.0)); + painter.line_segment([v, e], stroke(accent(painter), 1.0)); painter.circle_filled(e, r, fill); - painter.circle_stroke(e, r, stroke(ACCENT, 1.0)); + painter.circle_stroke(e, r, stroke(accent(painter), 1.0)); } /// Vector edit mode: a layer's segments, handles and points (`points`) with its `hover`ed part, and @@ -1313,7 +1336,7 @@ fn vector_overlay( // Paint bucket target: hatched like a fill to come. if active && !ov.face.is_empty() { let polys: Vec> = ov.face.iter().map(|poly| poly.iter().map(sp).collect()).collect(); - hatched(painter, &polys, ACCENT); + hatched(painter, &polys, accent(painter)); } if active { region_hatch(app, painter); @@ -1331,7 +1354,7 @@ fn vector_overlay( } else { 1.5 }; - painter.add(egui::epaint::CubicBezierShape::from_points_stroke([a, b, c, d], false, Color32::TRANSPARENT, stroke(ACCENT, w))); + painter.add(egui::epaint::CubicBezierShape::from_points_stroke([a, b, c, d], false, Color32::TRANSPARENT, stroke(accent(painter), w))); } if !points { return; @@ -1339,7 +1362,7 @@ fn vector_overlay( for h in &o.handles { let hovered = hover == Some(VHit::Handle { seg: h.seg, start: h.start }); let on = o.selected_handles.contains(&(h.seg, h.start)); - bezier_handle(painter, sp(&h.vertex), sp(&h.handle), if hovered { 4.5 } else { 3.5 }, if on { ACCENT } else { Color32::WHITE }); + bezier_handle(painter, sp(&h.vertex), sp(&h.handle), if hovered { 4.5 } else { 3.5 }, if on { accent(painter) } else { Color32::WHITE }); } for (i, v) in o.vertices.iter().enumerate() { let p = sp(v); @@ -1347,14 +1370,14 @@ fn vector_overlay( let size = if hovered { 9.0 } else { 7.0 }; let r = Rect::from_center_size(p, vec2(size, size)); let on = o.selected_vertices.contains(&i); - painter.rect_filled(r, CornerRadius::same(5), if on { ACCENT } else { Color32::WHITE }); - painter.rect_stroke(r, CornerRadius::same(5), stroke(ACCENT, 1.0), StrokeKind::Inside); + painter.rect_filled(r, CornerRadius::same(5), if on { accent(painter) } else { Color32::WHITE }); + painter.rect_stroke(r, CornerRadius::same(5), stroke(accent(painter), 1.0), StrokeKind::Inside); if o.anchor == Some(i) { - painter.circle_stroke(p, 7.5, stroke(ACCENT, 1.0)); + painter.circle_stroke(p, 7.5, stroke(accent(painter), 1.0)); } } if let Some([x0, y0, x1, y1]) = ov.rubber.filter(|_| active) { - painter.add(Shape::dashed_line(&[app.to_screen(x0, y0), app.to_screen(x1, y1)], stroke(ACCENT, 1.0), 4.0, 3.0)); + painter.add(Shape::dashed_line(&[app.to_screen(x0, y0), app.to_screen(x1, y1)], stroke(accent(painter), 1.0), 4.0, 3.0)); } } @@ -1371,7 +1394,7 @@ fn region_hatch(app: &FigApp, painter: &egui::Painter) { let r = painter.clip_rect(); let clip = figcraft_geom::Rect::new(f64::from(r.min.x), f64::from(r.min.y), f64::from(r.max.x), f64::from(r.max.y)); for [a, b] in app.session.vector_region_hatch(view, Some(clip)) { - painter.line_segment([pos2(a.x as f32, a.y as f32), pos2(b.x as f32, b.y as f32)], stroke(ACCENT, REGION_HATCH_WIDTH)); + painter.line_segment([pos2(a.x as f32, a.y as f32), pos2(b.x as f32, b.y as f32)], stroke(accent(painter), REGION_HATCH_WIDTH)); } } @@ -1386,10 +1409,10 @@ fn builder_overlay(app: &FigApp, painter: &egui::Painter, t: &Tokens, arr: &figc for r in &arr.regions { for poly in screen(r) { painter.add(Shape::closed_line(poly.clone(), stroke(Color32::from_white_alpha(150), 2.5))); - painter.add(Shape::closed_line(poly, stroke(ACCENT, 1.0))); + painter.add(Shape::closed_line(poly, stroke(accent(painter), 1.0))); } } - let col = if ov.builder_remove { t.danger } else { ACCENT }; + let col = if ov.builder_remove { t.danger } else { accent(painter) }; for r in ov.builder_regions.iter().filter_map(|i| arr.regions.get(*i)) { hatched(painter, &screen(r), col); } @@ -1499,8 +1522,16 @@ fn rulers(app: &FigApp, painter: &egui::Painter, t: &Tokens, doc: &Document, sel // Highlight the selection's span. if let Some(b) = doc.bounds_of(sel) { let (a, bb) = (app.to_screen(b.x0, b.y0), app.to_screen(b.x1, b.y1)); - painter.rect_filled(Rect::from_min_max(pos2(a.x, r.min.y), pos2(bb.x, r.min.y + size)), CornerRadius::ZERO, ACCENT.gamma_multiply(0.15)); - painter.rect_filled(Rect::from_min_max(pos2(r.min.x, a.y), pos2(r.min.x + size, bb.y)), CornerRadius::ZERO, ACCENT.gamma_multiply(0.15)); + painter.rect_filled( + Rect::from_min_max(pos2(a.x, r.min.y), pos2(bb.x, r.min.y + size)), + CornerRadius::ZERO, + accent(painter).gamma_multiply(0.15), + ); + painter.rect_filled( + Rect::from_min_max(pos2(r.min.x, a.y), pos2(r.min.x + size, bb.y)), + CornerRadius::ZERO, + accent(painter).gamma_multiply(0.15), + ); } let mut x = (p0.x / step).floor() * step; while x <= p1.x { @@ -1528,3 +1559,8 @@ pub fn fmt(v: f64) -> String { #[cfg(test)] #[path = "zoom_preview_tests.rs"] mod zoom_preview_tests; + +/// The overlay green for the page this painter draws on ([`crate::theme::canvas_accent`]). +fn accent(p: &egui::Painter) -> Color32 { + crate::theme::canvas_accent(p.ctx()) +} diff --git a/crates/ui-egui/src/cjk.rs b/crates/ui-egui/src/cjk.rs index fd3ecf8c..35323efe 100644 --- a/crates/ui-egui/src/cjk.rs +++ b/crates/ui-egui/src/cjk.rs @@ -1,8 +1,9 @@ -//! Chinese, Japanese and Korean text in the UI. egui's own fonts and Inter have no CJK glyphs, so -//! the UI adds a face per script it needs: the UI language's, the scripts in the open file's names +//! Chinese, Japanese, Korean and Bengali text in the UI. egui's own fonts and Inter have no glyphs +//! for them, so the UI adds a face per script it needs: the UI language's, the scripts in the open file's names //! and comments, the language picker's and typed text. Desktop finds them among the installed //! fonts (the system font scan), only once one is needed, so English users load none; the web -//! build has only the CJK faces built in from craft-fonts (`FIGCRAFT_EMBED_CJK`), else none. +//! build has only the CJK faces built in from craft-fonts (`FIGCRAFT_EMBED_CJK`), else none (no +//! Bengali face is built in, so the web leaves Bengali out). use std::sync::Arc; @@ -12,6 +13,13 @@ pub const KANA: u8 = 1; pub const HANGUL: u8 = 2; /// Han characters (and CJK punctuation and full-width forms). pub const HAN: u8 = 4; +/// Bengali script. +pub const BENGALI: u8 = 8; +/// Every script bit: nothing more to look for once all have a face. +pub const ALL: u8 = KANA | HANGUL | HAN | BENGALI; + +/// Bengali faces: cross-platform, Windows, macOS, then Linux names. +const BENGALI_FONTS: &[&str] = &["Noto Sans Bengali", "Nirmala UI", "Bangla Sangam MN", "Kalpurush", "SolaimanLipi"]; /// Japanese faces, the built-in one first: Windows, macOS, then Linux names. const JAPANESE: &[&str] = &[ @@ -46,36 +54,47 @@ const CHINESE: &[&str] = &[ "Droid Sans Fallback", ]; -/// The CJK scripts in `s`. +/// The CJK and Bengali scripts in `s`. pub fn scripts(s: &str) -> u8 { s.chars().fold(0, |m, c| { m | match u32::from(c) { 0x3040..=0x30FF | 0x31F0..=0x31FF | 0xFF66..=0xFF9F => KANA, 0x1100..=0x11FF | 0x3130..=0x318F | 0xA960..=0xA97F | 0xAC00..=0xD7FF | 0xFFA0..=0xFFDC => HANGUL, 0x3000..=0x303F | 0x3400..=0x4DBF | 0x4E00..=0x9FFF | 0xF900..=0xFAFF | 0xFF01..=0xFF65 | 0x20000..=0x3FFFF => HAN, + 0x0980..=0x09FF => BENGALI, _ => 0, } }) } -/// The CJK scripts a UI language (a catalog code) is written in. +/// The scripts a UI language (a catalog code) is written in. pub fn language(code: &str) -> u8 { match code.split('-').next() { Some("ja") => KANA | HAN, Some("ko") => HANGUL, Some("zh") => HAN, + Some("bn") => BENGALI, _ => 0, } } /// Whether the UI can draw language `code`: always on desktop (a system face is found when it's -/// needed; tofu at worst), on the web only with built-in faces for its scripts. The language -/// picker and the system language skip a language it can't. +/// needed; tofu at worst), on the web only with built-in faces for its scripts (Georgian: craft-fonts' +/// Noto Sans Georgian). The language picker and the system language skip a language it can't. pub fn can_show(code: &str) -> bool { + shows(code, cfg!(target_arch = "wasm32")) +} + +/// [`can_show`] in a desktop or (`web`) a web build. +fn shows(code: &str, web: bool) -> bool { + if !web { + return true; + } + if code.split('-').next() == Some("ka") { + return figcraft_text::face_with_char('ა', &["Noto Sans Georgian"]).is_some(); + } let need = language(code); - need == 0 - || !cfg!(target_arch = "wasm32") - || samples(need, 0).iter().all(|(c, _)| figcraft_text::face_with_char(*c, &["BIZ UDPGothic", "Noto Sans CJK SC"]).is_some()) + need == 0 || samples(need, 0).iter().all(|(c, _)| figcraft_text::face_with_char(*c, &["BIZ UDPGothic", "Noto Sans CJK SC"]).is_some()) } /// A character standing for each script in `need`, with the families to look for it in, the UI @@ -89,10 +108,16 @@ fn samples(need: u8, ui: u8) -> Vec<(char, Vec<&'static str>)> { (false, HANGUL) => ('漢', [KOREAN, JAPANESE, CHINESE].concat()), (false, _) => ('漢', [JAPANESE, KOREAN, CHINESE].concat()), }; - let mut v = vec![(KANA, ('あ', JAPANESE.to_vec())), (HAN, han), (HANGUL, ('한', KOREAN.to_vec()))]; + let mut v = vec![(KANA, ('あ', JAPANESE.to_vec())), (HAN, han), (HANGUL, ('한', KOREAN.to_vec())), (BENGALI, ('অ', BENGALI_FONTS.to_vec()))]; match ui { HANGUL => v.reverse(), HAN => v.swap(0, 1), + BENGALI => { + if let Some(pos) = v.iter().position(|(bit, _)| *bit == BENGALI) { + let item = v.remove(pos); + v.insert(0, item); + } + } _ => {} } v.into_iter().filter(|(bit, _)| need & bit != 0).map(|(_, s)| s).collect() @@ -124,20 +149,32 @@ mod tests { assert_eq!(scripts("ボタン"), KANA); assert_eq!(scripts("버튼"), HANGUL); assert_eq!(scripts("按钮"), HAN); + assert_eq!(scripts("বাংলা"), BENGALI); assert_eq!(scripts("日本語のレイヤー(1)"), KANA | HAN); assert_eq!(language("ja"), KANA | HAN); assert_eq!(language("ko"), HANGUL); assert_eq!(language("zh-Hant"), HAN); assert_eq!(language("zh-Hans"), HAN); + assert_eq!(language("bn"), BENGALI); assert_eq!(language("fr"), 0); assert!(["en", "es", "es-419", "fr", "de", "pt-BR", "tr", "id"].iter().all(|c| can_show(c))); } + #[test] + fn the_web_offers_georgian_only_with_its_face() { + assert!(shows("ka", false), "desktop always lists Georgian"); + let face = figcraft_text::face_with_char('ა', &["Noto Sans Georgian"]).is_some(); + assert_eq!(shows("ka", true), face); + assert_eq!(shows("ka-GE", true), face); + assert!(shows("en", true) && shows("tr", true)); + } + #[test] fn the_ui_language_script_comes_first() { let order = |need, ui| samples(need, ui).into_iter().map(|(c, _)| c).collect::(); let first_face = |need, ui| samples(need, ui).first().and_then(|(_, f)| f.first()).copied(); assert_eq!(order(KANA | HAN | HANGUL, 0), "あ漢한"); + assert_eq!(order(KANA | HAN | HANGUL | BENGALI, language("bn")), "অあ漢한"); assert_eq!(order(KANA | HAN | HANGUL, KANA | HAN), "あ漢한"); assert_eq!(order(KANA | HAN | HANGUL, HANGUL), "한漢あ"); assert_eq!(order(HANGUL, 0), "한"); @@ -150,6 +187,6 @@ mod tests { assert_eq!(order(KANA | HAN, language("zh-Hans")), "们あ"); assert!(["Microsoft YaHei UI", "PingFang SC", "Noto Sans CJK SC"].iter().all(|f| samples(HAN, HAN)[0].1[..4].contains(f))); assert!(faces(0, "en", &|_| false).is_empty()); - assert!(faces(KANA | HANGUL | HAN, "ja", &|_| true).is_empty(), "nothing is looked up for scripts the UI draws"); + assert!(faces(KANA | HANGUL | HAN | BENGALI, "ja", &|_| true).is_empty(), "nothing is looked up for scripts the UI draws"); } } diff --git a/crates/ui-egui/src/colorpicker.rs b/crates/ui-egui/src/colorpicker.rs index f4a27c5e..9d37477a 100644 --- a/crates/ui-egui/src/colorpicker.rs +++ b/crates/ui-egui/src/colorpicker.rs @@ -120,8 +120,7 @@ fn current_paint(app: &FigApp, target: &PickerTarget) -> Option { /// Write `paint` to the target. `live` edits are part of an ongoing drag. fn write(app: &mut FigApp, target: &PickerTarget, paint: &Paint, live: bool) { if let PickerTarget::PluginField { plugin, control, .. } = target { - let c = paint.solid_color().unwrap_or(Color::BLACK); - let hex = c.with_alpha(c.a * paint.opacity).to_css_hex(); + let hex = paint.solid_color().unwrap_or(Color::BLACK).to_css_hex(); let (plugin, control) = (plugin.clone(), control.clone()); if !live { crate::plugins_ui::set_field(app, &plugin, &control, &hex); @@ -153,7 +152,7 @@ fn write(app: &mut FigApp, target: &PickerTarget, paint: &Paint, live: bool) { } PickerTarget::Variable { id, mode } => { let c = paint.solid_color().unwrap_or(Color::BLACK); - app.session.execute("variable.set", &json!({"variable": id, "mode": mode, "value": c.with_alpha(c.a * paint.opacity).to_css_hex()})) + app.session.execute("variable.set", &json!({"variable": id, "mode": mode, "value": c.to_css_hex()})) } PickerTarget::PluginField { .. } => return, PickerTarget::Style { id, index } => { @@ -436,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 { @@ -464,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)) @@ -471,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); @@ -773,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); @@ -1014,7 +1032,7 @@ fn saved_preset_row(ui: &mut Ui, name: &str) -> Option { let del_resp = ui.interact(del, ui.id().with(("cp_preset_del", name)), Sense::click()).tip(tr("Delete preset")); let hot = resp.hovered() || del_resp.hovered(); if hot { - ui.painter().rect_filled(r, CornerRadius::same(5), crate::theme::ACCENT); + ui.painter().rect_filled(r, CornerRadius::same(5), t.menu_hover); } let col = if hot { Color32::WHITE } else { t.text }; ui.painter().text(pos2(r.min.x + 8.0, r.center().y), Align2::LEFT_CENTER, name, regular(11.5), col); @@ -1181,6 +1199,22 @@ mod selection_color_tests { assert_eq!(app.session.doc().unwrap().node(a).unwrap().fills[0].solid_color().unwrap().to_hex(), "FF0000"); } + #[test] + fn writing_a_variable_keeps_its_alpha() { + // The picker holds a colour variable's alpha as the paint opacity; writing it back applies it once. + let (mut app, _, _) = setup(); + app.session.execute("variable.createCollection", &json!({"name":"T"})).unwrap(); + let v = app.session.execute("variable.create", &json!({"collection":"T","name":"tint","type":"color","value":"#0D6EFD80"})).unwrap(); + let var = app.session.doc().unwrap().variables.get(v["id"].as_str().unwrap()).unwrap().clone(); + let target = PickerTarget::Variable { id: var.id.clone(), mode: var.values.keys().next().unwrap().clone() }; + for _ in 0..2 { + let paint = current_paint(&app, &target).unwrap(); + write(&mut app, &target, &paint, false); + } + let c = current_paint(&app, &target).unwrap().solid_color().unwrap(); + assert_eq!(c.to_css_hex().to_uppercase(), "#0D6EFD80"); + } + #[test] fn picker_does_not_follow_selection_or_document_switches() { let (mut app, _, _) = setup(); 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/comments.rs b/crates/ui-egui/src/comments.rs index 5918dfc0..d33c81ee 100644 --- a/crates/ui-egui/src/comments.rs +++ b/crates/ui-egui/src/comments.rs @@ -189,10 +189,10 @@ pub fn cluster(points: &[Pos2], r: f32) -> Vec> { } /// A message's text with its @mentions of `people` picked out, wrapped at `width`. -pub fn rich_text(text: &str, people: &[&str], color: Color32, width: f32) -> LayoutJob { +pub fn rich_text(text: &str, people: &[&str], color: Color32, mention_color: Color32, width: f32) -> LayoutJob { let mut job = LayoutJob::default(); let plain = TextFormat { font_id: regular(12.0), color, ..Default::default() }; - let mention = TextFormat { font_id: semibold(12.0), color: ACCENT, ..Default::default() }; + let mention = TextFormat { font_id: semibold(12.0), color: mention_color, ..Default::default() }; let mut at = 0; for r in mention_ranges(text, people) { job.append(text.get(at..r.start).unwrap_or(""), 0.0, plain.clone()); @@ -400,7 +400,7 @@ pub fn draw(app: &FigApp, painter: &egui::Painter) { if let Some((x, y, _)) = &app.ui.comments.draft { let r = pin_rect(app.to_screen(*x, *y)); painter.rect_filled(r, BUBBLE, ACCENT); - painter.circle_filled(r.center(), 3.0, Color32::WHITE); + painter.circle_filled(r.center(), 3.0, crate::theme::ACCENT_ON); } } @@ -729,7 +729,7 @@ fn message( ui.add_space(indent); let mut names: Vec<&str> = cx.people.iter().map(String::as_str).collect(); names.push(cx.me); - ui.add(egui::Label::new(rich_text(&m.text, &names, t.text, ui.available_width())).wrap()); + ui.add(egui::Label::new(rich_text(&m.text, &names, t.text, t.accent_text, ui.available_width())).wrap()); }); } } @@ -1062,7 +1062,8 @@ pub fn panel(app: &mut FigApp, ui: &mut Ui) { ui.add_space(16.0); ui.label(egui::RichText::new(tr(app.ui.comments.filter.label())).font(regular(11.0)).color(t.text_secondary)); ui.add_space(4.0); - if ui.add(egui::Label::new(egui::RichText::new(tr("Show open")).font(regular(11.0)).color(ACCENT)).sense(Sense::click())).clicked() { + if ui.add(egui::Label::new(egui::RichText::new(tr("Show open")).font(regular(11.0)).color(t.accent_text)).sense(Sense::click())).clicked() + { app.ui.comments.filter = Filter::Open; } }); diff --git a/crates/ui-egui/src/comments_tests.rs b/crates/ui-egui/src/comments_tests.rs index 696ff322..eea2278e 100644 --- a/crates/ui-egui/src/comments_tests.rs +++ b/crates/ui-egui/src/comments_tests.rs @@ -7,7 +7,7 @@ use egui::{Event, Key, Modifiers, Pos2, pos2, vec2}; use figcraft_engine::Session; use serde_json::{Value, json}; -use crate::comments::{cluster, insert_mention, mention_matches, mention_query}; +use crate::comments::{cluster, insert_mention, mention_matches, mention_query, start_draft}; use crate::present_tests::{Ui, id}; fn add(s: &mut Session, x: f64, y: f64, text: &str, author: &str) -> u64 { @@ -41,6 +41,35 @@ fn comments(u: &Ui) -> Value { u.app.inspect()["comments"].clone() } +#[test] +fn switching_from_comment_to_move_closes_the_unposted_composer() { + let mut u = Ui::new(Session::new()); + u.app.run("tool.set", json!({"tool": "comment"})); + start_draft(&mut u.app, 100.0, 80.0); + assert!(comments(&u)["draft"].is_array()); + + u.app.run("tool.set", json!({"tool": "move"})); + u.frame(vec![]); + assert!(comments(&u)["draft"].is_null()); +} + +#[test] +fn control_channel_tool_switch_closes_the_unposted_composer() { + let mut u = Ui::new(Session::new()); + u.app.run("tool.set", json!({"tool": "comment"})); + start_draft(&mut u.app, 100.0, 80.0); + assert!(comments(&u)["draft"].is_array()); + + let invalid = json!({"command": "tool.set", "params": {"tool": "not-a-tool"}}); + assert!(crate::control::handle(&mut u.app, &egui::Context::default(), "engine.execute", &invalid).is_err()); + assert!(comments(&u)["draft"].is_array()); + + crate::control::handle(&mut u.app, &egui::Context::default(), "engine.execute", &json!({"command": "tool.set", "params": {"tool": "move"}})) + .unwrap(); + u.frame(vec![]); + assert!(comments(&u)["draft"].is_null()); +} + #[test] fn mention_helpers() { // The `@query` before the cursor. diff --git a/crates/ui-egui/src/component_panel.rs b/crates/ui-egui/src/component_panel.rs index 6c9ff7d4..6caab6d5 100644 --- a/crates/ui-egui/src/component_panel.rs +++ b/crates/ui-egui/src/component_panel.rs @@ -2,8 +2,9 @@ //! //! - An instance: its main component (with a swap menu), variant dropdowns, property values, //! the selected layer's overrides, and Reset / Detach / Go to main / Push changes. -//! - A component or component set: its properties (add, rename, default, delete) and, for a set, -//! the variant property matrix. +//! - A component or component set: its properties (add, rename, default, delete), Add variant +//! (a lone main component becomes a set) and, for a set, the variant property matrix. +//! - Several main components: Combine as variants. //! - A layer inside a component: bind its visibility / text / instance to a property, or make a //! frame a slot. //! - A slot inside an instance (and an instance's slot properties): fill it with components @@ -11,6 +12,7 @@ use egui::{Id, Sense, Ui, vec2}; use figcraft_doc::*; +use figcraft_engine::cmd::component::{can_add_variant, can_combine_variants}; use figcraft_engine::components::{ PROP_BINDING, default_variant, inside_instance, owning_component, owning_instance, slot_def, slot_edited, slots_of, variant_conflicts, variant_options, variant_props, variant_set, @@ -27,7 +29,10 @@ use crate::widgets; /// Entry point (called from the Design panel header for every selection). pub fn instance_section(app: &mut FigApp, ui: &mut Ui, doc: &Document, sel: &[NodeId]) { - let [id] = sel else { return }; + let [id] = sel else { + combine_ui(app, ui, doc, sel); + return; + }; let Some(n) = doc.node(*id) else { return }; match &n.kind { NodeKind::Instance { .. } => instance_ui(app, ui, doc, n), @@ -57,6 +62,19 @@ pub fn instance_section(app: &mut FigApp, ui: &mut Ui, doc: &Document, sel: &[No } } +/// Two or more main components selected: a button combines them into a component set. +fn combine_ui(app: &mut FigApp, ui: &mut Ui, doc: &Document, sel: &[NodeId]) { + if !can_combine_variants(doc, sel) { + return; + } + row(ui, 32.0, |ui| { + let label = crate::i18n::command("component.combineVariants", "Combine as variants"); + if widgets::dropdown(ui, &label, 160.0, Some(Icon::ComponentSet)).clicked() { + app.run("component.combineVariants", json!({"ids": sel})); + } + }); +} + /// A component's description: a multi-line field, saved when it loses focus. fn description_ui(app: &mut FigApp, ui: &mut Ui, id: NodeId, description: &str) { widgets::separator(ui); @@ -70,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"); @@ -365,7 +383,8 @@ fn properties_ui(app: &mut FigApp, ui: &mut Ui, doc: &Document, owner: NodeId) { }); } } - if is_set { + // A lone main component too: Add variant makes it a set of itself and the new variant. + if can_add_variant(doc, owner) { row(ui, 32.0, |ui| { if widgets::dropdown(ui, tr("Add variant"), 120.0, Some(Icon::Plus)).clicked() { app.run("component.addVariant", json!({"id": owner})); @@ -502,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"), @@ -689,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 } => { @@ -730,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 a4a8dd15..edd9fff7 100644 --- a/crates/ui-egui/src/control.rs +++ b/crates/ui-egui/src/control.rs @@ -239,6 +239,7 @@ pub fn handle(app: &mut FigApp, ctx: &Context, method: &str, p: &Value) -> Resul let cmd = p.get("command").and_then(Value::as_str).ok_or("`command` required")?; let params = p.get("params").cloned().unwrap_or(json!({})); let r = app.session.execute(cmd, ¶ms).map_err(|e| e.to_string()); + app.clear_comment_draft_after_tool_set(cmd, r.is_ok()); // Programmatic calls never open dialogs. app.session.ui_requests.retain(|u| !u.kind.starts_with("dialog.")); app.handle_ui_requests(); @@ -335,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(); @@ -632,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 cc006506..763f00e6 100644 --- a/crates/ui-egui/src/control_roots_tests.rs +++ b/crates/ui-egui/src/control_roots_tests.rs @@ -57,6 +57,24 @@ fn a_confined_app_writes_only_inside_its_write_root() { assert!(app.session.execute("file.export", &json!({"format": "svg", "path": p("outside/own.svg")})).is_ok()); } +#[test] +fn a_confined_app_saves_only_inside_its_write_root() { + // #836: saving over the control channel, both as a new file and to the current one. + let (mut app, d) = confined_app("save"); + let p = |rel: &str| d.0.join(rel).to_string_lossy().into_owned(); + for cmd in ["file.saveAs", "file.save"] { + let e = handle(&mut app, "engine.execute", json!({"command": cmd, "params": {"path": p("outside/a.figcraft")}})).unwrap_err(); + assert!(e.contains("automation path rejected"), "{cmd}: {e}"); + let e = handle(&mut app, "engine.execute", json!({"command": cmd, "params": {"path": p("out/../outside/b.figcraft")}})).unwrap_err(); + assert!(e.contains("automation path rejected"), "{cmd}: {e}"); + } + assert_eq!(std::fs::read_dir(d.0.join("outside")).unwrap().count(), 0, "nothing written outside the root"); + handle(&mut app, "engine.execute", json!({"command": "file.saveAs", "params": {"path": p("out/in.figcraft")}})).unwrap(); + assert!(d.0.join("out/in.figcraft").is_file()); + // Saving again goes to the same file, inside the root. + handle(&mut app, "engine.execute", json!({"command": "file.save", "params": {}})).unwrap(); +} + #[test] fn window_captures_are_written_through_the_roots_or_returned() { let (app, d) = confined_app("capture"); @@ -72,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 d3456088..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})); } }); } @@ -628,7 +642,7 @@ pub fn dev_resources_section(app: &mut FigApp, ui: &mut Ui, doc: &Document, id: } icons::paint(ui.painter(), Rect::from_center_size(pos2(r.min.x + PAD + 7.0, r.center().y), vec2(14.0, 14.0)), Icon::Link, t.icon_secondary); let x = r.min.x + PAD + 22.0; - ui.painter().text(pos2(x, r.center().y - 7.0), Align2::LEFT_CENTER, widgets::elide(ui, name, text_w), semibold(11.0), ACCENT); + ui.painter().text(pos2(x, r.center().y - 7.0), Align2::LEFT_CENTER, widgets::elide(ui, name, text_w), semibold(11.0), t.accent_text); let below = match item.from.and_then(|f| doc.node(f)) { Some(m) => trf("From {name}", &[("name", &m.name)]), None => url.clone(), @@ -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 448501ab..85d8ca15 100644 --- a/crates/ui-egui/src/dialogs.rs +++ b/crates/ui-egui/src/dialogs.rs @@ -3,7 +3,7 @@ use std::path::Path; -use egui::{Align2, Color32, Context, CornerRadius, RichText, Sense, Stroke, vec2}; +use egui::{Align2, Context, CornerRadius, RichText, Sense, Stroke, vec2}; use figcraft_color::ColorProfile; use figcraft_doc::{ExportFormat, ExportSetting, ExportSize, NodeId}; use figcraft_engine::cmd::file::{export_jobs, exportable_layers}; @@ -12,8 +12,7 @@ use serde_json::{Value, json}; use crate::FigApp; use crate::export_panel::{export_layers, save_one, suggested_name}; use crate::i18n::{self, tr, tr_noop, trf, trn}; -use crate::icons::{self, Icon}; -use crate::theme::{ACCENT, Tokens, regular, semibold}; +use crate::theme::{Tokens, regular, semibold}; pub const VERSION: &str = env!("CARGO_PKG_VERSION"); @@ -122,10 +121,10 @@ pub(crate) fn tab_strip(ui: &mut egui::Ui, t: &Tokens, tabs fn about_body(ui: &mut egui::Ui, t: &Tokens) { ui.horizontal(|ui| { let (r, _) = ui.allocate_exact_size(vec2(48.0, 48.0), Sense::hover()); - ui.painter().rect_filled(r, CornerRadius::same(10), ACCENT); - icons::paint(ui.painter(), r.shrink(10.0), Icon::Logo, Color32::WHITE); + crate::theme::paint_app_icon(ui, r); ui.vertical(|ui| { - ui.label(RichText::new("FigCraft").font(semibold(18.0))); + let (wordmark, _) = ui.allocate_exact_size(vec2(130.0, 23.0), Sense::hover()); + crate::theme::paint_wordmark(ui.painter(), egui::pos2(wordmark.min.x, wordmark.center().y), 18.0, t); ui.label(RichText::new(trf("Version {version}", &[("version", &VERSION)])).color(t.text_secondary)); }); }); @@ -143,8 +142,12 @@ fn about_body(ui: &mut egui::Ui, t: &Tokens) { } /// Escape closes the topmost simple dialog (one without menus of its own), before the canvas's -/// shortcuts see it (it doesn't deselect), like a system dialog: from one of its fields too. +/// shortcuts see it (it doesn't deselect), like a system dialog: from one of its fields too. A menu +/// open over the dialog (Preferences ▸ Interface size) takes the first Escape and closes alone. pub fn keys(app: &mut FigApp, ctx: &Context) { + if egui::Popup::is_any_open(ctx) { + return; + } let u = &mut app.ui; let open = u.color_profile.is_some() || u.menu_input.is_some() @@ -223,6 +226,9 @@ pub fn show(app: &mut FigApp, ctx: &Context) { if !cfg!(target_arch = "wasm32") && crate::widgets::checkbox(ui, prefs.auto_save, tr("Save files automatically")) { toggles.push("pref.autoSave"); } + if !cfg!(target_arch = "wasm32") && crate::widgets::checkbox(ui, prefs.start_on_home, tr("Start on the Home screen")) { + toggles.push("pref.startOnHome"); + } for c in toggles { app.run(c, json!({})); } @@ -273,6 +279,8 @@ pub fn show(app: &mut FigApp, ctx: &Context) { } }); ui.add_space(8.0); + interface_size(app, ui, prefs.ui_scale); + ui.add_space(8.0); ui.horizontal(|ui| { ui.label(tr("Theme")); for (l, c) in [(tr_noop("Light"), "view.themeLight"), (tr_noop("Dark"), "view.themeDark"), (tr_noop("System"), "view.themeSystem")] { @@ -473,6 +481,76 @@ pub(crate) fn batch_rename_params(b: &BatchRenameUi) -> serde_json::Value { p } +/// Preferences ▸ Interface size: a size relative to the system's display scale (100 % is the +/// system's own), as a drop-down that takes effect at once. The browser has its own zoom, so there +/// the row only says so. +fn interface_size(app: &mut FigApp, ui: &mut egui::Ui, chosen: f64) { + let t = Tokens::get(ui.ctx()); + if cfg!(target_arch = "wasm32") { + ui.label(tr("Interface size")); + crate::widgets::label(ui, tr("Use your browser's zoom to change the interface size."), t.text_secondary); + return; + } + let percent = |size: f64| trf("{percent}%", &[("percent", &crate::ui_scale::percent(size as f32))]); + let shown = percent(chosen); + ui.horizontal(|ui| { + ui.label(tr("Interface size")); + 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 { + 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( + ui, + tr("Relative to your system's display scaling (100% is the system's own size). Document zoom is separate."), + t.text_secondary, + ); + // Said in words, not only by the menu's value: the window is too small for the size chosen. + if app.ui.ui_scale.limited { + let note = trf( + "The window is too small for {size}%, so the interface is drawn at {drawn}%. Make the window larger to use {size}%.", + &[("size", &crate::ui_scale::percent(chosen as f32)), ("drawn", &crate::ui_scale::percent(app.ui.ui_scale.zoom))], + ); + crate::widgets::label(ui, ¬e, t.text_secondary); + } +} + /// Rename layers: Match, Rename to with buttons for the current name and numbers, where numbers /// start, a preview of the first new names, then one `object.batchRename`. fn batch_rename_dialog(app: &mut FigApp, ctx: &Context) { diff --git a/crates/ui-egui/src/field_tests.rs b/crates/ui-egui/src/field_tests.rs index 9ddc48e5..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(); @@ -549,3 +601,88 @@ fn a_label_too_long_for_its_field_ends_in_an_ellipsis_before_the_value() { assert!(field.contains_rect(*l) && field.contains_rect(*v), "both inside the field {field:?}: {l:?} {v:?}"); assert!(l.max.x < v.min.x, "the label stops before the value: {l:?} {v:?}"); } + +/// The middle of the field with response `id`. +fn at(u: &Ui, id: Id) -> Pos2 { + u.ctx.read_response(id.with("field")).unwrap_or_else(|| panic!("no field {id:?}")).rect.center() +} + +/// Type `text` into the field whose response is `id` and press Enter. +fn type_at(u: &mut Ui, id: Id, text: &str) { + let p = at(u, id); + click(u, p); + u.frame(vec![Event::Text(text.into())]); + key(u, Key::Enter, Modifiers::NONE); + u.frame(vec![]); +} + +/// A frame with one layout grid, its settings open in the Design panel. +fn grid_ui(grid: serde_json::Value) -> (Ui, NodeId) { + let mut s = Session::new(); + let f = id(s.execute("frame.create", &json!({"x": 0, "y": 0, "width": 400, "height": 300})).unwrap()); + s.execute("node.set", &json!({"ids": [f], "props": {"grids": [grid]}})).unwrap(); + s.execute("select.set", &json!({"ids": [f]})).unwrap(); + let mut u = Ui::new(s); + u.ctx.data_mut(|d| d.insert_temp(Id::new(("grid_open", 0usize)), true)); + u.frame(vec![]); + (u, f) +} + +/// Scroll the Design panel (over its own field `id`) until that field is on the test screen: the +/// grid settings sit below the fold. +fn scroll_to(u: &mut Ui, id: Id) { + let wheel = Event::MouseWheel { + unit: egui::MouseWheelUnit::Point, + delta: egui::vec2(0.0, -300.0), + phase: egui::TouchPhase::Move, + modifiers: Modifiers::NONE, + }; + for _ in 0..10 { + let p = at(u, id); + if p.y < 860.0 { + break; + } + u.frame(vec![Event::PointerMoved(pos2(p.x, 500.0)), wheel.clone()]); + // Let the scroll settle. + for _ in 0..30 { + u.frame(vec![]); + } + } +} + +/// The word for Auto types 0 only where 0 *is* the automatic value: a fixed layout grid's count +/// does, an auto layout's gap (Auto there is "Space between") does not. Keying it on the field's +/// placeholder said 0 everywhere, which turned the gap's mode off. +#[test] +fn typing_auto_is_zero_only_where_zero_is_the_automatic_value() { + let count_id = Id::new(("grid_field", "count", 0usize)); + let (mut u, f) = grid_ui(json!({"kind": "columns", "align": "min", "count": 3, "size": 8, "gutter": 10})); + let count = |u: &Ui| u.app.session.doc().unwrap().node(f).unwrap().grids[0].count; + scroll_to(&mut u, count_id); + type_at(&mut u, count_id, "Auto"); + assert_eq!(count(&u), 0, "a fixed grid's count: Auto is 0"); + type_at(&mut u, count_id, "4"); + assert_eq!(count(&u), 4, "and a number is that count"); + // A stretched grid works its width out from the frame: the field shows Auto and takes no edit. + let (mut u, f) = grid_ui(json!({"kind": "columns", "align": "stretch", "count": 3, "size": 0})); + let size_id = Id::new(("grid_field", "size", 0usize)); + scroll_to(&mut u, size_id); + type_at(&mut u, size_id, "40"); + assert_eq!(u.app.session.doc().unwrap().node(f).unwrap().grids[0].size, 0.0, "a stretched grid's width isn't set"); + // Nor its count: a stretched grid has at least one band, so the word commits nothing. + type_at(&mut u, count_id, "Auto"); + assert_eq!(u.app.session.doc().unwrap().node(f).unwrap().grids[0].count, 3, "a stretched grid keeps its count"); + + // The auto layout's gap: its placeholder says Auto, but Auto is a mode, not 0. + let mut s = Session::new(); + let f = id(s.execute("frame.create", &json!({"x": 0, "y": 0, "width": 400, "height": 300})).unwrap()); + s.execute("layout.add", &json!({"ids": [f], "direction": "vertical"})).unwrap(); + s.execute("node.set", &json!({"ids": [f], "props": {"mainAlign": "spaceBetween"}})).unwrap(); + s.execute("select.set", &json!({"ids": [f]})).unwrap(); + let mut u = Ui::new(s); + u.frame(vec![]); + let gap = |u: &Ui| u.app.session.doc().unwrap().node(f).unwrap().layout.as_ref().unwrap().gap; + let before = gap(&u); + type_at(&mut u, Id::new("al_gap"), "Auto"); + assert_eq!(gap(&u), before, "the word isn't a value where Auto is a mode"); +} diff --git a/crates/ui-egui/src/frame_labels.rs b/crates/ui-egui/src/frame_labels.rs index 79557141..9e8b2e7a 100644 --- a/crates/ui-egui/src/frame_labels.rs +++ b/crates/ui-egui/src/frame_labels.rs @@ -103,7 +103,7 @@ pub fn draw(app: &FigApp, painter: &Painter, t: &Tokens, doc: &Document, id: Nod let col = if is_comp { COMPONENT } else if selected || hovered { - ACCENT + crate::theme::canvas_accent(painter.ctx()) } else { t.text_secondary }; diff --git a/crates/ui-egui/src/handles.rs b/crates/ui-egui/src/handles.rs index ca38bee7..ec489bf8 100644 --- a/crates/ui-egui/src/handles.rs +++ b/crates/ui-egui/src/handles.rs @@ -11,7 +11,7 @@ use figcraft_geom::Point; use serde_json::json; use crate::colorpicker::PickerTarget; -use crate::theme::{ACCENT, semibold}; +use crate::theme::semibold; use crate::{FigApp, RightTab}; pub(crate) const PINK: Color32 = Color32::from_rgb(0xF2, 0x48, 0x8C); @@ -293,7 +293,12 @@ pub fn draw(app: &FigApp, painter: &egui::Painter) { painter.rect_filled(r, CornerRadius::same(2), crate::theme::doc_c32(painter.ctx(), s.color.with_alpha(1.0))); painter.rect_stroke(r, CornerRadius::same(2), Stroke::new(2.0, Color32::WHITE), StrokeKind::Outside); if i == selected { - painter.rect_stroke(r.expand(2.0), CornerRadius::same(3), Stroke::new(1.5, ACCENT), StrokeKind::Outside); + painter.rect_stroke( + r.expand(2.0), + CornerRadius::same(3), + Stroke::new(1.5, crate::theme::canvas_accent(painter.ctx())), + StrokeKind::Outside, + ); } } for (i, p) in [a, b, c].iter().enumerate() { @@ -301,7 +306,7 @@ pub fn draw(app: &FigApp, painter: &egui::Painter) { continue; } painter.circle_filled(*p, 5.0, Color32::WHITE); - painter.circle_stroke(*p, 5.0, Stroke::new(1.5, ACCENT)); + painter.circle_stroke(*p, 5.0, Stroke::new(1.5, crate::theme::canvas_accent(painter.ctx()))); } return; } @@ -311,8 +316,8 @@ pub fn draw(app: &FigApp, painter: &egui::Painter) { painter.line_segment([a, b], Stroke::new(1.0, Color32::from_black_alpha(120))); // The start is hollow (least blur), the end filled (most blur by default). painter.circle_filled(a, 5.0, Color32::WHITE); - painter.circle_stroke(a, 5.0, Stroke::new(1.5, ACCENT)); - painter.circle_filled(b, 5.0, ACCENT); + painter.circle_stroke(a, 5.0, Stroke::new(1.5, crate::theme::canvas_accent(painter.ctx()))); + painter.circle_filled(b, 5.0, crate::theme::canvas_accent(painter.ctx())); painter.circle_stroke(b, 5.0, Stroke::new(1.5, Color32::WHITE)); return; } @@ -320,11 +325,11 @@ pub fn draw(app: &FigApp, painter: &egui::Painter) { let Some(c) = crop_corners(app, id, r) else { return }; let outline = vec![c[0], c[1], c[2], c[3], c[0]]; painter.add(Shape::line(outline.clone(), Stroke::new(2.0, Color32::WHITE))); - painter.add(Shape::line(outline, Stroke::new(1.0, ACCENT))); + painter.add(Shape::line(outline, Stroke::new(1.0, crate::theme::canvas_accent(painter.ctx())))); for p in c { let hr = Rect::from_center_size(p, vec2(8.0, 8.0)); painter.rect_filled(hr, CornerRadius::ZERO, Color32::WHITE); - painter.rect_stroke(hr, CornerRadius::ZERO, Stroke::new(1.0, ACCENT), StrokeKind::Inside); + painter.rect_stroke(hr, CornerRadius::ZERO, Stroke::new(1.0, crate::theme::canvas_accent(painter.ctx())), StrokeKind::Inside); } return; } diff --git a/crates/ui-egui/src/home.rs b/crates/ui-egui/src/home.rs index 236e2786..326a3b14 100644 --- a/crates/ui-egui/src/home.rs +++ b/crates/ui-egui/src/home.rs @@ -180,31 +180,19 @@ pub fn show(app: &mut FigApp, ui: &mut Ui) { fn dropped(app: &mut FigApp, ctx: &egui::Context) { let files = ctx.input(|i| i.raw.dropped_files.clone()); for f in files { - let path = f.path().to_path_buf(); - if !crate::opens(&path) { + if !crate::opens(f.path()) { app.toast(tr("Drop a .figcraft, .fig, .json, .svg or image file.")); continue; } - // `opens` saw an extension, so there is a file name to tell the format by. - let name = path.file_name().map(|n| n.to_string_lossy().to_string()).unwrap_or_default(); - let params = if path.exists() { - json!({"path": path.to_string_lossy()}) - } else if let Some(bytes) = crate::dropped_bytes(&f) { - use base64::Engine as _; - let data = base64::engine::general_purpose::STANDARD.encode(&bytes); - json!({"data": data, "name": name}) - } else { - continue; - }; - app.ui.pending_opens.push(crate::pending_open::PendingOpen { label: name, params, command: "file.open", target: None }); + crate::pending_open::open_dropped(app, &f); } } fn sidebar(app: &mut FigApp, ui: &mut Ui, t: &Tokens, left: Rect) { let mut y = left.min.y + 20.0; - icons::paint(ui.painter(), Rect::from_min_size(pos2(left.min.x + 16.0, y), vec2(20.0, 20.0)), Icon::Logo, ACCENT); - ui.painter().text(pos2(left.min.x + 44.0, y + 10.0), Align2::LEFT_CENTER, "FigCraft", semibold(14.0), t.text); - y += 48.0; + crate::theme::paint_app_icon(ui, Rect::from_min_size(pos2(left.min.x + 12.0, y - 4.0), vec2(32.0, 32.0))); + crate::theme::paint_wordmark(ui.painter(), pos2(left.min.x + 52.0, y + 12.0), 16.0, t); + y += 52.0; let search = Rect::from_min_size(pos2(left.min.x + 8.0, y), vec2(SIDEBAR - 16.0, widgets::FIELD_H)); ui.scope_builder(egui::UiBuilder::new().max_rect(search), |ui| { let response = @@ -324,8 +312,8 @@ fn action_card(ui: &mut Ui, t: &Tokens, icon: Icon, title: &str, sub: &str) -> b } card_frame(ui, t, r, &resp); let ir = Rect::from_min_size(pos2(r.min.x + 16.0, r.center().y - 16.0), vec2(32.0, 32.0)); - ui.painter().rect_filled(ir, CornerRadius::same(6), ACCENT); - icons::paint(ui.painter(), ir.shrink(8.0), icon, Color32::WHITE); + ui.painter().rect_filled(ir, CornerRadius::same(6), t.accent); + icons::paint(ui.painter(), ir.shrink(8.0), icon, t.accent_on); ui.painter().text(pos2(r.min.x + 60.0, r.center().y - 8.0), Align2::LEFT_CENTER, title_shown, semibold(12.0), t.text); ui.painter().text(pos2(r.min.x + 60.0, r.center().y + 9.0), Align2::LEFT_CENTER, sub_shown, regular(11.0), t.text_secondary); resp.clicked() @@ -333,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 { @@ -388,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 aeeb0f27..2d39c6fe 100644 --- a/crates/ui-egui/src/home_recents.rs +++ b/crates/ui-egui/src/home_recents.rs @@ -10,6 +10,7 @@ use std::collections::HashMap; use std::path::Path; +use chrono::Datelike; use egui::{Align2, Color32, CornerRadius, Id, Key, Rect, Response, Sense, Stroke, StrokeKind, Ui, pos2, vec2}; use figcraft_engine::Prefs; use figcraft_format::thumbnail::{FileKind, Preview}; @@ -200,18 +201,35 @@ const MONTHS: [&str; 12] = [ tr_noop("Dec"), ]; -/// `secs` since 1970 as a date in UTC: "Mar 4, 2026". +/// (days since 1970-01-01, seconds-of-day, nanos) → (proleptic Gregorian year, the same wall-clock +/// instant moved into chrono's range by whole 400-year cycles). The calendar repeats every 400 +/// years (146_097 days), so month, day and time of day come from chrono and only the year is +/// carried, which keeps the result exact for any `i64` day count. This is not hand-rolled calendar +/// math: chrono still does the date, the cycle count just shifts the year. +fn utc_parts(days: i64, sod: u32, nanos: u32) -> (i64, chrono::NaiveDateTime) { + let (cycles, rest) = (days.div_euclid(146_097), days.rem_euclid(146_097)); + // `rest + 719_163` (1970-01-01 is CE day 719_163) is always a valid CE day, and `sod` < 86_400. + let date = i32::try_from(rest + 719_163).ok().and_then(chrono::NaiveDate::from_num_days_from_ce_opt); + let time = chrono::NaiveTime::from_num_seconds_from_midnight_opt(sod.min(86_399), nanos.min(999_999_999)); + match (date, time) { + (Some(d), Some(t)) => { + let y = i128::from(d.year()) + i128::from(cycles) * 400; + (i64::try_from(y).unwrap_or(if y < 0 { i64::MIN } else { i64::MAX }), d.and_time(t)) + } + _ => (1970, chrono::NaiveDateTime::default()), // unreachable + } +} + +/// `secs` since 1970 as a date in UTC: "Mar 4, 2026". Years past chrono's own range (262143) keep +/// converting: the instant is moved into range by whole 400-year cycles and the real year restored, +/// so every `u64` reads as the date the old hand-rolled conversion produced. fn date(secs: u64) -> String { - // Days to a civil date (Howard Hinnant's days_from_civil, inverted). - let z = i64::try_from(secs / 86_400).unwrap_or(0) + 719_468; - let era = z.div_euclid(146_097); - let doe = z.rem_euclid(146_097); - let yoe = (doe - doe / 1460 + doe / 36_524 - doe / 146_096) / 365; - let doy = doe - (365 * yoe + yoe / 4 - yoe / 100); - let mp = (5 * doy + 2) / 153; - let day = doy - (153 * mp + 2) / 5 + 1; - let month = if mp < 10 { mp + 3 } else { mp - 9 }; - let year = yoe + era * 400 + i64::from(month <= 2); + // The day count of any `u64` seconds fits in `i64` (`u64::MAX / 86_400` ≈ 2.1e14), so the + // conversion is always exact (no epoch fallback). Splitting with `u64` arithmetic (as the old + // code did) avoids the `i64` seconds overflow near `u64::MAX`. + let (days, sod) = (i64::try_from(secs / 86_400).unwrap_or(i64::MAX), (secs % 86_400) as u32); + let (year, dt) = utc_parts(days, sod, 0); + let (month, day) = (i64::from(dt.month()), i64::from(dt.day())); let name = usize::try_from(month - 1).ok().and_then(|m| MONTHS.get(m)).map_or("", |m| tr(m)); trf("{month} {day}, {year}", &[("month", &name), ("day", &day), ("year", &year)]) } @@ -234,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 { @@ -241,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 { @@ -293,6 +313,7 @@ struct Ready { modified: Option, page: Option, texture: Option, + kind: figcraft_doc::FileKind, /// When it came in (for its fade). arrived: f64, } @@ -461,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; } } @@ -501,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. @@ -733,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), ACCENT); - icons::paint(ui.painter(), r.shrink(3.0), Icon::Logo, Color32::WHITE); + ui.painter().rect_filled(r, CornerRadius::same(4), t.accent); + 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); @@ -849,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); @@ -917,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 { @@ -927,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), @@ -965,3 +994,24 @@ fn list(ui: &mut Ui, t: &Tokens, recents: &mut Recents, files: &[Shown], now: u6 arrows(ui, recents, files, 0, 1); action } + +#[cfg(test)] +mod tests { + use super::*; + + /// `date` keeps the exact "Mon D, YYYY" (UTC) output the hand-rolled days-from-civil code + /// produced. With no catalog loaded the month and format strings pass through in English. + #[test] + fn date_formats_seconds_since_1970_in_utc() { + assert_eq!(date(0), "Jan 1, 1970"); + assert_eq!(date(951_782_400), "Feb 29, 2000"); + assert_eq!(date(1_709_164_800), "Feb 29, 2024"); + assert_eq!(date(1_741_046_400), "Mar 4, 2025"); + // A year past chrono's own `NaiveDate` range (262143): the instant is moved into range by + // whole 400-year cycles and the real year restored. + assert_eq!(date(253_402_300_800), "Jan 1, 10000"); + // Past chrono's year range, `u64::MAX` keeps the old hand-rolled output instead of falling + // back to the epoch. + assert_eq!(date(u64::MAX), "Nov 9, 584554051223"); + } +} diff --git a/crates/ui-egui/src/home_recents_tests.rs b/crates/ui-egui/src/home_recents_tests.rs index 1c3235dd..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] @@ -256,9 +256,11 @@ fn rects_of(s: &[Shape], c: egui::Color32) -> usize { s.iter().filter(|s| matches!(s, Shape::Rect(r) if r.fill == c)).count() } -/// Images drawn (meshes with a texture of their own). +/// Preview images in the main content area, excluding the branded sidebar icon. fn images(s: &[Shape]) -> usize { - s.iter().filter(|s| matches!(s, Shape::Mesh(m) if m.texture_id != egui::TextureId::default())).count() + s.iter() + .filter(|s| matches!(s, Shape::Mesh(m) if m.texture_id != egui::TextureId::default() && m.vertices.iter().any(|v| v.pos.x > 240.0))) + .count() } #[test] diff --git a/crates/ui-egui/src/home_tests.rs b/crates/ui-egui/src/home_tests.rs index c493a0a1..ebbf6d95 100644 --- a/crates/ui-egui/src/home_tests.rs +++ b/crates/ui-egui/src/home_tests.rs @@ -573,6 +573,71 @@ fn dropping_a_file_without_one_on_disk_opens_it_as_a_new_unsaved_document() { assert!(!path.exists(), "nothing is written"); } +/// What a drop's read gives, once it has arrived. +type Arrival = std::rc::Rc, String>>>>; + +/// A drop whose contents arrive later, as every drop in the browser does: pending until `arrived` +/// holds them. +fn later(arrived: &Arrival) -> crate::ReadFuture { + let arrived = arrived.clone(); + Box::pin(std::future::poll_fn(move |_| match arrived.borrow_mut().take() { + Some(bytes) => std::task::Poll::Ready(bytes), + None => std::task::Poll::Pending, + })) +} + +#[test] +fn a_dropped_file_whose_contents_arrive_later_opens_once_they_do() { + let svg = br#""#.to_vec(); + let arrived = std::rc::Rc::new(std::cell::RefCell::new(None)); + let mut u = first_run(); + u.app.ui.home = true; + u.app.ui.pending_opens.push(crate::pending_open::PendingOpen { + label: "Browser drop.svg".into(), + params: json!({"name": "Browser drop.svg"}), + command: "file.open", + target: None, + read: Some(later(&arrived)), + }); + // egui lays a new card out invisibly on its first frame. + u.frame(vec![]); + for _ in 0..5 { + let s = flat(&u.frame_output(vec![]).shapes); + assert!(has_text(&s, "Browser drop.svg"), "the card shows while the contents are read"); + } + assert!(u.app.session.docs.is_empty(), "nothing opens before the contents arrive"); + *arrived.borrow_mut() = Some(Ok(svg)); + for _ in 0..3 { + u.frame(vec![]); + } + assert!(u.app.ui.pending_opens.is_empty()); + assert_eq!(u.app.ui.last_error, None); + assert_eq!(u.app.session.docs.len(), 1, "the contents open"); + assert_eq!(u.app.session.docs[0].path, None); + assert!(u.app.session.docs[0].doc.nodes().any(|n| matches!(n.kind, figcraft_doc::NodeKind::Rectangle)), "the SVG's content"); + assert!(!u.app.ui.home); +} + +#[test] +fn a_dropped_file_that_cannot_be_read_says_so_and_opens_nothing() { + let arrived = std::rc::Rc::new(std::cell::RefCell::new(Some(Err(crate::unreadable("Unreadable.figcraft"))))); + let mut u = first_run(); + u.app.ui.home = true; + u.app.ui.pending_opens.push(crate::pending_open::PendingOpen { + label: "Unreadable.figcraft".into(), + params: json!({"name": "Unreadable.figcraft"}), + command: "file.open", + target: None, + read: Some(later(&arrived)), + }); + for _ in 0..3 { + u.frame(vec![]); + } + assert!(u.app.ui.pending_opens.is_empty()); + assert!(u.app.session.docs.is_empty()); + assert_eq!(u.app.ui.last_error.as_deref(), Some("Couldn't read Unreadable.figcraft.")); +} + #[test] fn dropping_a_native_file_on_the_home_screen_opens_it() { let dir = Temp::new(); @@ -603,9 +668,8 @@ fn dropping_a_native_file_on_the_home_screen_opens_it() { #[test] fn dropping_a_native_file_on_canvas_keeps_its_receiving_document_after_tab_switch() { let dir = Temp::new(); - let path = dir.path("Canvas import.figcraft"); - let mut source = Session::new(); - source.execute("file.saveAs", &json!({"path": path.to_string_lossy()})).unwrap(); + let path = dir.path("Canvas import.svg"); + std::fs::write(&path, r#""#).unwrap(); let mut session = Session::new(); let target_uid = session.ds().unwrap().uid; @@ -631,7 +695,82 @@ fn dropping_a_native_file_on_canvas_keeps_its_receiving_document_after_tab_switc u.frame(vec![]); assert_eq!(u.app.session.ds().unwrap().uid, other_uid, "the user's active tab stays in front"); - assert_eq!(u.app.session.docs.iter().map(|doc| doc.doc.pages.len()).collect::>(), [2, 1]); + let layers = |uid: u64| { + let doc = &u.app.session.docs.iter().find(|doc| doc.uid == uid).unwrap().doc; + doc.node(doc.pages[0]).unwrap().children.len() + }; + assert_eq!((layers(target_uid), layers(other_uid)), (1, 0), "the drop lands in the tab that received it"); +} + +#[test] +fn dropping_a_document_on_canvas_opens_it_as_a_new_file() { + let dir = Temp::new(); + let path = dir.path("Dropped.figcraft"); + let mut saved = Session::new(); + saved.execute("file.saveAs", &json!({"path": path.to_string_lossy()})).unwrap(); + let mut u = Ui::new(Session::new()); + u.app.ui.home = false; + u.frame(vec![]); + let before = u.app.session.doc().unwrap().clone(); + let s = drop_file(&mut u, &path); + assert!(has_text(&s, "Drop to open"), "dragging a document over the canvas says it opens"); + let s = flat(&u.frame_output(vec![]).shapes); + assert!(has_text(&s, "Opening as a new file"), "the card says so too"); + u.frame(vec![]); + assert!(u.app.ui.pending_opens.is_empty()); + assert_eq!(u.app.session.docs.len(), 2, "a tab of its own"); + assert_eq!(u.app.session.ds().unwrap().path.as_deref(), Some(path.to_string_lossy().as_ref()), "in front"); + assert_eq!(u.app.session.docs[0].doc.as_ref(), &before, "the file that was open gets no pages added"); +} + +#[test] +fn a_document_dropped_on_canvas_without_a_file_on_disk_opens_as_a_new_file() { + // Every drop in the browser (#916). + let dir = Temp::new(); + let saved = dir.path("Saved.figcraft"); + Session::new().execute("file.saveAs", &json!({"path": saved.to_string_lossy()})).unwrap(); + let bytes = std::fs::read(&saved).unwrap(); + let mut u = Ui::new(Session::new()); + u.app.ui.home = false; + u.frame(vec![]); + let pos = u.app.canvas.rect.center(); + let file: egui::DroppedFileHandle = std::sync::Arc::new(DroppedBytes("Shared.figcraft".into(), bytes)); + u.frame_raw(egui::RawInput { events: vec![Event::PointerMoved(pos)], dropped_files: vec![file], ..Default::default() }); + for _ in 0..3 { + u.frame(vec![]); + } + assert!(u.app.ui.pending_opens.is_empty()); + assert_eq!(u.app.ui.last_error, None); + assert_eq!(u.app.session.docs.len(), 2, "a tab of its own"); + assert_eq!(u.app.session.docs[0].doc.pages.len(), 1, "no pages added to the open file"); + assert_eq!(u.app.session.docs[1].path, None, "with no save path made up for it"); +} + +#[test] +fn dropping_several_files_on_canvas_places_them_side_by_side() { + let dir = Temp::new(); + let svg = r##""##; + let paths: Vec<_> = ["a.svg", "b.svg", "c.svg"].iter().map(|n| dir.path(n)).collect(); + for path in &paths { + std::fs::write(path, svg).unwrap(); + } + let mut u = Ui::new(Session::new()); + u.app.ui.home = false; + u.frame(vec![]); + let pos = u.app.canvas.rect.center(); + let files: Vec = paths.iter().map(|p| std::sync::Arc::new(Dropped(p.clone())) as egui::DroppedFileHandle).collect(); + u.frame_raw(egui::RawInput { events: vec![Event::PointerMoved(pos)], dropped_files: files, ..Default::default() }); + for _ in 0..4 { + u.frame(vec![]); + } + assert!(u.app.ui.pending_opens.is_empty()); + let doc = u.app.session.doc().unwrap(); + let page = u.app.session.page().unwrap(); + let rects: Vec<_> = doc.node(page).unwrap().children.iter().map(|c| doc.world_bounds(*c).unwrap()).collect(); + assert_eq!(rects.len(), 3); + for pair in rects.windows(2) { + assert!(pair[1].x0 > pair[0].x1, "left to right with a gap: {rects:?}"); + } } #[test] diff --git a/crates/ui-egui/src/i18n.rs b/crates/ui-egui/src/i18n.rs index 22ea1a72..9faff001 100644 --- a/crates/ui-egui/src/i18n.rs +++ b/crates/ui-egui/src/i18n.rs @@ -27,7 +27,8 @@ pub struct Lang { pub code: &'static str, /// The language's own name for itself (the language picker shows it untranslated). pub name: &'static str, - /// Machine-drafted, waiting for a native speaker's review (`[meta] status`). + /// A draft (`[meta] status` set): waiting for a native speaker's review, or reviewed and waiting for + /// a maintainer's approval. pub draft: bool, /// Index into [`LANGS`] of the catalog a regional one overlays ("es" under "es-419": it lists /// only what differs), else 0 (English). @@ -171,9 +172,10 @@ fn plural(code: &str, n: usize) -> Plural { "cs" if n == 1 => Plural::One, "cs" if (2..=4).contains(&n) => Plural::Few, "cs" => Plural::Other, + "pl" if n == 1 => Plural::One, "ru" if n % 10 == 1 && n % 100 != 11 => Plural::One, - "ru" if (2..=4).contains(&(n % 10)) && !(12..=14).contains(&(n % 100)) => Plural::Few, - "ru" => Plural::Many, + "ru" | "pl" if (2..=4).contains(&(n % 10)) && !(12..=14).contains(&(n % 100)) => Plural::Few, + "ru" | "pl" => Plural::Many, "ja" | "ko" | "zh-Hans" => Plural::Other, "fr" | "pt-BR" if n <= 1 => Plural::One, _ if n == 1 => Plural::One, @@ -182,7 +184,7 @@ fn plural(code: &str, n: usize) -> Plural { } // CLDR's million-count "many" in French, Spanish and Portuguese still reads their existing -// "other" wording. Czech supplies separate few forms; Russian supplies few/many forms. +// "other" wording. Czech supplies separate few forms; Russian and Polish supply few/many forms. /// Whether count `n` takes the "one" form: 0 and 1 in French and Brazilian Portuguese, never in /// Japanese, Korean and Chinese (no plural forms: always "other"), 1 elsewhere (CLDR's integer rules). diff --git a/crates/ui-egui/src/i18n_tests.rs b/crates/ui-egui/src/i18n_tests.rs index d4d81d7b..fa7da946 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,9 +130,13 @@ 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); + v.push(figcraft_engine::live::TOO_LARGE_TO_JOIN); // The starter templates' names, descriptions and kinds. v.extend(figcraft_engine::templates::TEMPLATES.iter().flat_map(|t| [t.name, t.description, t.kind.label()])); v @@ -273,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 { @@ -301,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}"); } } } @@ -327,6 +350,14 @@ fn lookups_fall_back_to_english_and_fill_placeholders() { assert!(matches!(lookup(es, k), Some(x) if std::ptr::eq(x, t))); } +#[test] +fn a_live_failure_the_ui_knows_is_read_back_for_translation() { + use figcraft_engine::live::TOO_LARGE_TO_JOIN; + let reason = TOO_LARGE_TO_JOIN.replace("{mb}", "1024"); + assert!(matches!(crate::live::failure_text(&reason), std::borrow::Cow::Owned(t) if t == reason)); + assert!(matches!(crate::live::failure_text("Couldn't reach the server"), std::borrow::Cow::Borrowed(_))); +} + #[test] fn system_languages_pick_the_first_one_with_a_catalog() { let code = |pref: &str, system: Option<&str>| LANGS[resolve(pref, system)].code; @@ -335,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"); @@ -359,8 +388,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)); } @@ -378,10 +405,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]; @@ -400,7 +428,7 @@ fn french_and_portuguese_count_zero_as_singular() { for code in ["fr", "pt-BR"] { assert!(one(code, 0) && one(code, 1) && !one(code, 2), "{code}"); } - for code in ["en", "es", "de", "cs"] { + for code in ["en", "es", "de", "cs", "pl"] { for n in [0, 1, 2, 3, 4, 5, 21] { assert_eq!(one(code, n), n == 1, "{code}: {n}"); } @@ -421,6 +449,33 @@ fn japanese_korean_and_chinese_have_no_plural_forms() { } } +#[test] +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}"); + } +} + +/// Release builds embed craft-fonts' Noto Sans Georgian (`theme::install_fonts`' built-in faces), +/// since Inter and egui's fonts have no Georgian: it draws every Georgian letter the catalog uses. +#[test] +fn the_built_in_georgian_face_draws_the_georgian_catalog() { + let Some(face) = figcraft_text::find_face("Noto Sans Georgian", "Regular").filter(|f| f.family == "Noto Sans Georgian") else { + eprintln!("built without craft-fonts' Noto Sans Georgian; skipping"); + return; + }; + let ka = &LANGS[LANGS.iter().position(|l| l.code == "ka").unwrap()]; + let georgian = |c: &char| matches!(u32::from(*c), 0x10A0..=0x10FF | 0x1C90..=0x1CBF | 0x2D00..=0x2D2F); + let mut letters: BTreeSet = BTreeSet::new(); + for (_, t) in ka.ui.iter().chain(ka.commands).chain(ka.menu) { + letters.extend(forms(t).into_iter().flat_map(str::chars).filter(georgian)); + } + assert!(letters.len() > 30, "{letters:?}"); + let missing: String = letters.iter().filter(|c| !face.has_char(**c)).collect(); + assert!(missing.is_empty(), "no glyph for {missing:?}"); +} + #[test] fn chinese_reads_simplified_and_traditional_has_no_catalog() { let code = |pref: &str, system: Option<&str>| LANGS[resolve(pref, system)].code; @@ -433,7 +488,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] @@ -447,28 +501,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}"); @@ -476,28 +520,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() { + assert_eq!(plural("pl", 1), Plural::One); + for n in [2, 3, 4, 22, 23, 24, 102, 104, 1_002] { + assert_eq!(plural("pl", n), Plural::Few, "{n}"); + } + 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}"); } } #[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 fa4fb66e..8275df5e 100644 --- a/crates/ui-egui/src/icons.rs +++ b/crates/ui-egui/src/icons.rs @@ -83,8 +83,15 @@ 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. + IndependentPadding, LinkOn, LinkOff, LayoutH, @@ -139,6 +146,11 @@ icons! { Fixed, PaddingH, PaddingV, + /// Individual padding sides: a frame with the controlled side filled. + PaddingTop, + PaddingRight, + PaddingBottom, + PaddingLeft, Gap, Reset, Detach, @@ -186,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). @@ -421,6 +444,7 @@ pub fn paint(painter: &Painter, rect: Rect, icon: Icon, color: Color32) { draw(&p, icon); } +/// Draw `icon` on the 16-unit grid through `p`, which maps grid units to the target box. fn draw(p: &P, icon: Icon) { use Icon::*; match icon { @@ -446,18 +470,23 @@ fn draw(p: &P, icon: Icon) { p.path(&h, false); } Frame => { - p.line(5.5, 2.0, 5.5, 14.0); - p.line(10.5, 2.0, 10.5, 14.0); - p.line(2.0, 5.5, 14.0, 5.5); - p.line(2.0, 10.5, 14.0, 10.5); + // Inset a unit from the box: four long hinted strokes read heavier than the + // anti-aliased outlines beside them at full size (#737). + p.line(5.5, 3.0, 5.5, 13.0); + p.line(10.5, 3.0, 10.5, 13.0); + p.line(3.0, 5.5, 13.0, 5.5); + p.line(3.0, 10.5, 13.0, 10.5); } Section | Section2 => { p.rect(2.5, 5.5, 13.5, 13.5, 1.0); p.fill_rect(2.0, 2.0, 9.0, 6.0, 1.0); } Slice => { - p.rect(2.5, 2.5, 13.5, 13.5, 0.0); - p.line(2.5, 13.5, 13.5, 2.5); + // A knife, one silhouette (#739): a slim blade to the top right, outlined like the + // rest of the row, and a solid handle whose end overlaps the blade's base so the two + // join at every size. + p.poly(&[(13.5, 2.5), (5.0, 8.0), (8.0, 11.0)], true); + p.fill(&capsule((2.8, 13.2), (5.9, 10.1), 1.3)); } Bend => { // A curve with a smooth point and its handle line. @@ -523,11 +552,12 @@ fn draw(p: &P, icon: Icon) { p.line(9.0, 4.5, 11.5, 7.0); } Text => { - p.line(2.0, 3.5, 13.0, 3.5); - p.line(7.5, 3.0, 7.5, 13.0); - p.line(2.5, 3.0, 2.5, 6.0); - p.line(12.5, 3.0, 12.5, 6.0); - p.line(5.0, 12.5, 10.0, 12.5); + // Inset like the Frame mark, so its long strokes don't outweigh the outlines (#737). + p.line(3.0, 3.5, 12.0, 3.5); + p.line(7.5, 3.0, 7.5, 12.5); + p.line(3.5, 3.0, 3.5, 5.5); + p.line(11.5, 3.0, 11.5, 5.5); + p.line(5.5, 12.5, 9.5, 12.5); } Comment => { let mut b: Vec<(f32, f32)> = arc_pts(8.5, 7.5, 5.0, 2.75, TAU + 1.95).collect(); @@ -694,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). @@ -706,6 +739,14 @@ fn draw(p: &P, icon: Icon) { p.corner(x, y, dx, dy, 4.0, 2.0); } } + IndependentPadding => { + // A frame outline with one inward tick per side: each side's padding, set alone. + p.rect(2.5, 2.5, 13.5, 13.5, 2.0); + p.line(8.0, 2.5, 8.0, 5.0); + p.line(13.5, 8.0, 11.0, 8.0); + p.line(8.0, 13.5, 8.0, 11.0); + p.line(2.5, 8.0, 5.0, 8.0); + } LinkOn | Link => { p.path(&capsule((4.0, 12.0), (6.5, 9.5), 2.4), true); p.path(&capsule((9.5, 6.5), (12.0, 4.0), 2.4), true); @@ -923,12 +964,32 @@ fn draw(p: &P, icon: Icon) { p.line(3.0, 7.5, 13.0, 7.5); } PaddingH => { - p.rect(2.5, 3.5, 13.5, 12.5, 1.0); - p.fill_rect(5.0, 6.0, 11.0, 10.0, 0.0); + // Horizontal padding: bars down the padded left and right sides in their frame. + p.rect(2.5, 2.5, 13.5, 13.5, 1.0); + p.fill_rect(2.5, 2.5, 5.5, 13.5, 1.0); + p.fill_rect(10.5, 2.5, 13.5, 13.5, 1.0); } PaddingV => { - p.rect(3.5, 2.5, 12.5, 13.5, 1.0); - p.fill_rect(6.0, 5.0, 10.0, 11.0, 0.0); + // Vertical padding: bars across the padded top and bottom in their frame. + p.rect(2.5, 2.5, 13.5, 13.5, 1.0); + p.fill_rect(2.5, 2.5, 13.5, 5.5, 1.0); + p.fill_rect(2.5, 10.5, 13.5, 13.5, 1.0); + } + PaddingTop => { + p.rect(2.5, 2.5, 13.5, 13.5, 1.0); + p.fill_rect(2.5, 2.5, 13.5, 5.5, 1.0); + } + PaddingRight => { + p.rect(2.5, 2.5, 13.5, 13.5, 1.0); + p.fill_rect(10.5, 2.5, 13.5, 13.5, 1.0); + } + PaddingBottom => { + p.rect(2.5, 2.5, 13.5, 13.5, 1.0); + p.fill_rect(2.5, 10.5, 13.5, 13.5, 1.0); + } + PaddingLeft => { + p.rect(2.5, 2.5, 13.5, 13.5, 1.0); + p.fill_rect(2.5, 2.5, 5.5, 13.5, 1.0); } Online => { p.circle(8.0, 8.0, 5.5); @@ -1076,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. @@ -1120,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); @@ -1226,4 +1370,73 @@ mod tests { assert!(!rotation.is_empty() && !radius.is_empty(), "both draw an icon"); assert_ne!(rotation, radius); } + + /// The shapes `icon` paints in a 32-point box at the origin: two points per grid unit. + fn shapes_at_32(icon: Icon) -> Vec { + let ctx = egui::Context::default(); + let mut out = + ctx.run_ui(egui::RawInput::default(), |ui| paint(ui.painter(), Rect::from_min_size(Pos2::ZERO, Vec2::splat(32.0)), icon, Color32::WHITE)); + out.textures_delta.clear(); + out.shapes.into_iter().map(|c| c.shape).collect() + } + + /// Frame and Text stay a grid unit inside the box, so their long hinted strokes don't + /// outweigh the outlines beside them in the toolbar (#737). + #[test] + fn frame_and_text_marks_are_inset() { + for icon in [Icon::Frame, Icon::Text] { + let pts: Vec = shapes_at_32(icon) + .iter() + .flat_map(|s| match s { + Shape::LineSegment { points, .. } => points.to_vec(), + other => panic!("{icon:?} draws straight strokes only: {other:?}"), + }) + .collect(); + assert!(!pts.is_empty()); + for p in pts { + // Grid units 3..13, two points each. + assert!((6.0..=26.0).contains(&p.x) && (6.0..=26.0).contains(&p.y), "{icon:?} reaches {p:?}"); + } + } + } + + /// Slice is one knife (#739): an outlined blade and a solid handle that covers the middle of + /// the blade's base, so the two read as one mark rather than separate pieces. + #[test] + fn slice_is_one_connected_knife() { + let shapes = shapes_at_32(Icon::Slice); + let paths: Vec<_> = shapes + .iter() + .map(|s| match s { + Shape::Path(p) => p, + other => panic!("Slice draws paths only: {other:?}"), + }) + .collect(); + let blade = paths.iter().find(|p| p.closed && p.fill == Color32::TRANSPARENT).expect("an outlined blade"); + let handle = paths.iter().find(|p| p.fill != Color32::TRANSPARENT).expect("a solid handle"); + assert_eq!((paths.len(), blade.points.len()), (2, 3), "a blade and a handle, nothing else"); + // The blade's base runs between its second and third points; the handle is convex. + let base = blade.points[1] + (blade.points[2] - blade.points[1]) / 2.0; + let h = &handle.points; + let side = |i: usize| { + let (a, b) = (h[i], h[(i + 1) % h.len()]); + (b - a).x * (base - a).y - (b - a).y * (base - a).x + }; + 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/idle_tests.rs b/crates/ui-egui/src/idle_tests.rs index 6ae19546..192073b3 100644 --- a/crates/ui-egui/src/idle_tests.rs +++ b/crates/ui-egui/src/idle_tests.rs @@ -26,6 +26,10 @@ pub(crate) struct Harness { impl Harness { pub(crate) fn new(s: Session) -> Self { + // The one system font scan bumps the layout generation, which makes the canvas render + // again: scanned here, before the first frame, rather than by whichever test happens to + // run first while this one waits for idle (#333). + figcraft_text::scan_system_fonts(); Self { ctx: egui::Context::default(), app: FigApp::new(s, Box::new(NoServices)), time: 0.0 } } diff --git a/crates/ui-egui/src/import_report_tests.rs b/crates/ui-egui/src/import_report_tests.rs index a2411582..8c10c7f3 100644 --- a/crates/ui-egui/src/import_report_tests.rs +++ b/crates/ui-egui/src/import_report_tests.rs @@ -177,6 +177,7 @@ fn a_dropped_file_shows_its_report_once_it_opens() { params: json!({"path": path.to_string_lossy()}), command: "file.open", target: None, + read: None, }); for _ in 0..6 { u.frame(vec![]); diff --git a/crates/ui-egui/src/layers.rs b/crates/ui-egui/src/layers.rs index 32f66f80..f172b309 100644 --- a/crates/ui-egui/src/layers.rs +++ b/crates/ui-egui/src/layers.rs @@ -184,13 +184,18 @@ fn pages(app: &mut FigApp, ui: &mut Ui) { app.ui.pages_open = !app.ui.pages_open; } let sr = Rect::from_center_size(pos2(hr.max.x - 50.0, hr.center().y), vec2(24.0, 24.0)); - let sresp = ui.interact(sr, ui.id().with("lsearch"), Sense::click()).tip(crate::shortcuts::tip(tr("Find"), "edit.findReplace")); - if sresp.hovered() { + let sresp = ui.interact(sr, ui.id().with("lsearch"), Sense::click()).tip(tr("Search pages")); + if sresp.hovered() || app.ui.page_search { ui.painter().rect_filled(sr, CornerRadius::same(5), t.hover); } icons::paint(ui.painter(), sr.shrink(5.0), Icon::Search, t.icon); + let search_id = Id::new("page_search_field"); if sresp.clicked() { - app.ui.find = Some((String::new(), String::new())); + app.ui.page_search = !app.ui.page_search; + app.ui.page_filter.clear(); + if app.ui.page_search { + app.ui.pages_open = true; + } } let ar = Rect::from_center_size(pos2(hr.max.x - 22.0, hr.center().y), vec2(24.0, 24.0)); let aresp = ui.interact(ar, ui.id().with("addpage"), Sense::click()).tip(tr("Add new page")); @@ -199,15 +204,22 @@ fn pages(app: &mut FigApp, ui: &mut Ui) { } icons::paint(ui.painter(), ar.shrink(5.0), Icon::Plus, t.icon); if aresp.clicked() { + app.ui.pages_focus = app.session.ds().ok().map(|d| d.uid); app.run("page.add", json!({})); } if !app.ui.pages_open { return; } + if app.ui.page_search { + search_field(ui, &t, w, search_id, tr("Search pages"), &mut app.ui.page_filter, &mut app.ui.page_search, sresp.clicked()); + } let Ok(ds) = app.session.ds() else { return }; let pages: Vec<(NodeId, String, bool)> = ds.doc.pages.iter().filter_map(|p| ds.doc.node(*p).map(|n| (*p, n.name.clone(), ds.doc.is_divider(*p)))).collect(); + let filter = app.ui.page_filter.trim().to_lowercase(); + let filtering = !filter.is_empty(); let cur = ds.page; + let uid = ds.uid; let sel = ds.selection.clone(); let mut select = None; let mut rename_done: Option<(NodeId, String)> = None; @@ -217,12 +229,27 @@ fn pages(app: &mut FigApp, ui: &mut Ui) { let mut move_to: Option<(NodeId, String)> = None; // Pages are dragged up and down the list to reorder them. let mut reorder = crate::list_drag::ListDrag::in_order(ui, "pages"); + let page_key = (uid, cur); + let scroll_to_cur = app.ui.pages_seen_page != Some(page_key); + let mut current_row_rendered = false; // A long list scrolls in its own area, so the layers keep their room below it. let max_h = pages_list_height(app.ui.pages_height, ui.available_height()); let mut moved = egui::ScrollArea::vertical().id_salt("pages_list").max_height(max_h).show(ui, |ui| { for (k, (id, name, divider)) in pages.iter().cloned().enumerate() { - let (r, resp) = ui.allocate_exact_size(vec2(w, 32.0), Sense::click_and_drag()); - reorder.row_by(&resp, k, r, || if divider { tr("Divider").to_string() } else { name.clone() }); + if filtering && (divider || !name.to_lowercase().contains(&filter)) { + continue; + } + let sense = if filtering { Sense::click() } else { Sense::click_and_drag() }; + let (r, resp) = ui.allocate_exact_size(vec2(w, 32.0), sense); + if id == cur { + current_row_rendered = true; + if scroll_to_cur { + ui.scroll_to_rect(r, None); + } + } + if !filtering { + reorder.row_by(&resp, k, r, || if divider { tr("Divider").to_string() } else { name.clone() }); + } a11y::list_item(&resp, if divider { tr("Divider") } else { &name }, id == cur); let r = r.shrink2(vec2(8.0, 0.0)); a11y::ring_around(ui, r, &resp); @@ -257,6 +284,8 @@ fn pages(app: &mut FigApp, ui: &mut Ui) { } if resp.clicked() && !divider { select = Some(id); + app.ui.pages_focus = Some(uid); + app.ui.layers_focus = false; } if resp.double_clicked() { app.ui.rename_page = Some((id, name.clone())); @@ -274,13 +303,16 @@ fn pages(app: &mut FigApp, ui: &mut Ui) { ui.close(); } if ui.button(tr("Delete page")).clicked() { - app.run("page.delete", json!({"id": id})); + delete_page(app, id); ui.close(); } }); } reorder.finish(ui) }); + if current_row_rendered { + app.ui.pages_seen_page = Some(page_key); + } // A drop outside the list's visible area (and the row gap at its edges) isn't on a page row, // even one scrolled out of view. if !ui.input(|i| i.pointer.latest_pos()).is_some_and(|p| moved.inner_rect.expand2(vec2(0.0, 3.0)).contains(p)) { @@ -311,6 +343,20 @@ fn pages(app: &mut FigApp, ui: &mut Ui) { pages_edge(app, ui, moved.inner_rect.height().min(moved.content_size.y)); } +/// The Pages list has the keyboard: a page row (or the add-page button) of the open file was +/// clicked last, and the list is still on screen. +pub(crate) fn pages_have_keyboard(app: &FigApp) -> bool { + let Ok(ds) = app.session.ds() else { return false }; + app.ui.pages_focus == Some(ds.uid) && app.ui.pages_open && !app.ui.left_collapsed && !ds.view.ui_hidden && app.ui.left_tab == LeftTab::File +} + +/// Delete `page` (no confirmation: Undo brings it back) and say so. +pub(crate) fn delete_page(app: &mut FigApp, page: NodeId) { + if app.run("page.delete", json!({"id": page})).is_some() { + app.toast(tr("Page deleted")); + } +} + /// The pages list's height in a panel with `avail` below the Pages header: the height asked for /// (by default 40% of it), at least three rows, and leaving the layers their header and three rows. pub(crate) fn pages_list_height(asked: Option, avail: f32) -> f32 { @@ -332,7 +378,7 @@ fn pages_edge(app: &mut FigApp, ui: &mut Ui, shown: f32) { if active { ui.ctx().set_cursor_icon(egui::CursorIcon::ResizeVertical); } - let (width, colour) = if active { (2.0, t.accent) } else { (1.0, t.border) }; + let (width, colour) = if active { (2.0, ACCENT) } else { (1.0, t.border) }; ui.painter().line_segment([line.left_center(), line.right_center()], Stroke::new(width, colour)); let asked = if resp.double_clicked() { Some(None) @@ -623,8 +669,10 @@ fn set_expanded_deep(doc: &Document, id: NodeId, open: bool, expanded: &mut BTre } } -/// The search row under the Layers header (live filter; Escape closes it). -fn search_field(app: &mut FigApp, ui: &mut Ui, t: &Tokens, w: f32, id: Id) { +/// The search row under the Pages or Layers header: a live filter `text`, taking the keyboard when +/// `focus` (just opened). Escape in it clears it and closes the row (`open`), and goes no further +/// (the canvas keeps its selection). +fn search_field(ui: &mut Ui, t: &Tokens, w: f32, id: Id, hint: &str, text: &mut String, open: &mut bool, focus: bool) { let (fr, _) = ui.allocate_exact_size(vec2(w, 32.0), Sense::hover()); let field = Rect::from_min_max(pos2(fr.min.x + 12.0, fr.min.y + 2.0), pos2(fr.max.x - 12.0, fr.max.y - 6.0)); ui.painter().rect_filled(field, CornerRadius::same(5), t.field); @@ -632,21 +680,25 @@ fn search_field(app: &mut FigApp, ui: &mut Ui, t: &Tokens, w: f32, id: Id) { let te_rect = Rect::from_min_max(pos2(field.min.x + 24.0, field.center().y - 7.0), pos2(field.max.x - 6.0, field.center().y + 7.0)); let mut child = ui.new_child(egui::UiBuilder::new().max_rect(te_rect)); let r = child.add( - egui::TextEdit::singleline(&mut app.ui.layer_filter) + egui::TextEdit::singleline(text) .id(id) .frame(egui::Frame::NONE) .font(regular(11.0)) - .hint_text(tr("Find layers")) + .hint_text(hint) .vertical_align(egui::Align::Center) .desired_width(te_rect.width()), ); - crate::widgets::select_all_on_focus(ui, &r, &app.ui.layer_filter); - if r.has_focus() { - ui.painter().rect_stroke(field, CornerRadius::same(5), Stroke::new(1.0, t.accent), egui::StrokeKind::Inside); + // After the field: the click that opened it ends elsewhere, which would take the focus back. + if focus { + r.request_focus(); } - if (r.has_focus() || r.lost_focus()) && ui.input(|i| i.key_pressed(egui::Key::Escape)) { - app.ui.layer_search = false; - app.ui.layer_filter.clear(); + crate::widgets::select_all_on_focus(ui, &r, text); + if r.has_focus() { + ui.painter().rect_stroke(field, CornerRadius::same(5), Stroke::new(1.0, ACCENT), egui::StrokeKind::Inside); + } + if (r.has_focus() || r.lost_focus()) && ui.input_mut(|i| i.consume_key(egui::Modifiers::NONE, egui::Key::Escape)) { + *open = false; + text.clear(); } } @@ -665,12 +717,9 @@ fn layers(app: &mut FigApp, ui: &mut Ui) { if sresp.clicked() { app.ui.layer_search = !app.ui.layer_search; app.ui.layer_filter.clear(); - if app.ui.layer_search { - ui.memory_mut(|m| m.request_focus(search_id)); - } } if app.ui.layer_search { - search_field(app, ui, &t, w, search_id); + search_field(ui, &t, w, search_id, tr("Find layers"), &mut app.ui.layer_filter, &mut app.ui.layer_search, sresp.clicked()); } let Ok(ds) = app.session.ds() else { return }; let doc = ds.doc.clone(); @@ -1064,6 +1113,7 @@ fn layers(app: &mut FigApp, ui: &mut Ui) { } if let Some((id, m)) = clicked { app.ui.layers_focus = true; + app.ui.pages_focus = None; let order: Vec = rows.iter().map(|r| r.id).collect(); let pos = |x: NodeId| order.iter().position(|o| *o == x); let anchor = app.ui.layer_anchor.filter(|a| sel.contains(a)).and_then(pos); diff --git a/crates/ui-egui/src/layers_behaviour_tests.rs b/crates/ui-egui/src/layers_behaviour_tests.rs index d4ef4f8a..4d987e66 100644 --- a/crates/ui-egui/src/layers_behaviour_tests.rs +++ b/crates/ui-egui/src/layers_behaviour_tests.rs @@ -199,6 +199,81 @@ fn page_row(u: &mut Ui, page: NodeId, back: NodeId) -> Pos2 { panic!("no row for page {page:?}"); } +#[test] +fn pages_search_button_opens_the_filter_and_escape_clears_it() { + let s = Session::new(); + let _ = s.page().unwrap(); + let mut u = Ui::new(s); + + // The search icon in the Pages header opens the page-name field, not Find and Replace. + click(&mut u, Pos2::new(246.0, 124.0), Modifiers::NONE); + assert!(u.app.ui.page_search, "the Pages search is open"); + assert!(u.app.ui.find.is_none(), "page search does not open Find and Replace"); + u.frame(vec![Event::Text("COMPON".into())]); + assert_eq!(u.app.ui.page_filter, "COMPON", "typing can start immediately"); + u.frame(vec![Event::Key { key: egui::Key::Escape, physical_key: None, pressed: true, repeat: false, modifiers: Modifiers::NONE }]); + assert!(!u.app.ui.page_search, "Escape closes page search"); + assert!(u.app.ui.page_filter.is_empty(), "Escape clears the filter"); +} + +#[test] +fn pages_search_leaves_escape_to_the_canvas_when_the_field_is_unfocused() { + let mut s = Session::new(); + let r = rect(&mut s, "r", 0.0, None); + s.execute("select.set", &json!({"ids": [r]})).unwrap(); + let mut u = Ui::new(s); + u.app.ui.page_search = true; + u.app.ui.page_filter = "COMPON".into(); + u.frame(vec![]); + + u.frame(vec![Event::Key { key: egui::Key::Escape, physical_key: None, pressed: true, repeat: false, modifiers: Modifiers::NONE }]); + + assert!(u.app.session.selection().is_empty(), "Escape deselects on the canvas"); + assert!(u.app.ui.page_search, "the page search stays open"); + assert_eq!(u.app.ui.page_filter, "COMPON", "and keeps its filter"); +} + +#[test] +fn escape_in_the_pages_search_does_not_reach_the_canvas() { + let mut s = Session::new(); + let r = rect(&mut s, "r", 0.0, None); + s.execute("select.set", &json!({"ids": [r]})).unwrap(); + let mut u = Ui::new(s); + click(&mut u, Pos2::new(246.0, 124.0), Modifiers::NONE); + assert!(u.app.ui.page_search); + u.frame(vec![Event::Text("x".into())]); + u.frame(vec![Event::Key { key: egui::Key::Escape, physical_key: None, pressed: true, repeat: false, modifiers: Modifiers::NONE }]); + assert!(!u.app.ui.page_search, "Escape closes the page search"); + assert_eq!(u.app.session.selection(), vec![r], "and leaves the selection alone"); +} + +#[test] +fn pages_search_filters_page_names_case_insensitively() { + let mut s = Session::new(); + let _overview = s.page().unwrap(); + let components = id(s.execute("page.add", &json!({"name": "Components"})).unwrap()); + let library = id(s.execute("page.add", &json!({"name": "Library"})).unwrap()); + s.execute("page.select", &json!({"id": library})).unwrap(); + let mut u = Ui::new(s); + u.app.ui.page_search = true; + u.app.ui.page_filter = "COMPON".into(); + let shapes = u.frame_output(vec![]).shapes; + let text = crate::live_tests::texts(&crate::live_tests::flat(&shapes)); + assert!(text.iter().any(|(label, _, _)| label == "Components"), "the matching page remains visible"); + assert!(!text.iter().any(|(label, _, _)| label == "Library"), "nonmatching pages are filtered out"); + + // Search leaves only Components; the active page starts as Library. + for y in (150..400).step_by(4) { + let p = Pos2::new(150.0, y as f32); + u.button(p, true); + u.button(p, false); + if u.app.session.page().unwrap() == components { + break; + } + } + assert_eq!(u.app.session.page().unwrap(), components, "matching is case-insensitive and opens the named page"); +} + #[test] fn pages_drag_to_reorder_and_dividers_open_nothing() { let mut s = Session::new(); @@ -432,3 +507,59 @@ fn the_pages_list_keeps_three_rows_and_leaves_the_layers_room() { // A panel too short for both keeps the three rows. assert_eq!(pages_list_height(Some(500.0), 100.0), 3.0 * ROW); } + +/// Selecting a page that is far down the list automatically scrolls it into view. +#[test] +fn selecting_a_distant_page_scrolls_it_into_view() { + let mut s = Session::new(); + let rect = id(s.execute("shape.rectangle", &json!({"x": 0, "y": 0, "width": 10, "height": 10})).unwrap()); + for _ in 0..40 { + s.execute("page.add", &json!({})).unwrap(); + } + let pages = s.doc().unwrap().pages.clone(); + let first = pages[0]; + let last = *pages.last().unwrap(); + s.execute("page.select", &json!({"id": first})).unwrap(); + s.execute("select.none", &json!({})).unwrap(); + let mut u = Ui::new(s); + let layer = u.row_at(rect); + u.frame(vec![]); + // Select the last page. + u.app.run("page.select", json!({"id": last})); + for _ in 0..15 { + u.frame(vec![]); + } + assert_eq!(u.app.ui.pages_seen_page, Some((u.app.session.ds().unwrap().uid, last))); + let opened = pages_down_to(&mut u, layer.y - 60.0); + let top = pages.iter().position(|p| Some(p) == opened.first()).unwrap(); + assert!(top > 20, "the list scrolled past its first pages to reveal the selection (top row: page {top})"); +} + +/// If the active page is filtered out by search, it is not marked as seen +/// until the filter is cleared and its row actually renders. +#[test] +fn filtered_out_active_page_is_seen_only_after_rendering() { + let mut s = Session::new(); + let p1 = s.doc().unwrap().pages[0]; + let p2 = id(s.execute("page.add", &json!({})).unwrap()); + s.execute("page.rename", &json!({"id": p1, "name": "Alpha"})).unwrap(); + s.execute("page.rename", &json!({"id": p2, "name": "Beta"})).unwrap(); + s.execute("page.select", &json!({"id": p1})).unwrap(); + let uid = s.ds().unwrap().uid; + let mut u = Ui::new(s); + assert_eq!(u.app.ui.pages_seen_page, Some((uid, p1))); + + // Filter to only "Alpha", then select "Beta" (p2). + u.app.ui.page_search = true; + u.app.ui.page_filter = "Alpha".into(); + u.app.run("page.select", json!({"id": p2})); + u.frame(vec![]); + + // Beta was filtered out and not rendered, so pages_seen_page has not recorded it. + assert_ne!(u.app.ui.pages_seen_page, Some((uid, p2))); + + // Clear filter: Beta renders now and is recorded. + u.app.ui.page_filter.clear(); + u.frame(vec![]); + assert_eq!(u.app.ui.pages_seen_page, Some((uid, p2))); +} diff --git a/crates/ui-egui/src/layers_keys_tests.rs b/crates/ui-egui/src/layers_keys_tests.rs index b78890a8..63f8ce74 100644 --- a/crates/ui-egui/src/layers_keys_tests.rs +++ b/crates/ui-egui/src/layers_keys_tests.rs @@ -1,8 +1,8 @@ //! Arrow keys in the layers panel once a row has been clicked: up/down through the rows (Shift //! extends), left/right to collapse, expand, and go to the parent or first child; a press on the -//! canvas gives arrows back to nudging. +//! canvas gives arrows back to nudging. Delete after a click in the Pages list deletes the page. -use egui::{Event, Key, Modifiers, pos2}; +use egui::{Event, Key, Modifiers, Pos2, pos2, vec2}; use figcraft_doc::NodeId; use figcraft_engine::Session; use serde_json::json; @@ -107,3 +107,125 @@ fn a_canvas_press_gives_arrows_back_to_nudging() { let doc = u.app.session.doc().unwrap(); assert_eq!(doc.node(c).unwrap().transform.translation().x, 201.0); } + +/// The middle of `page`'s row in the Pages list, found by clicking down the list until it opens; +/// then back to `back`. +fn page_row(u: &mut Ui, page: NodeId, back: NodeId) -> Pos2 { + for y in (90..400).step_by(4) { + let p = pos2(150.0, y as f32); + u.button(p, true); + u.button(p, false); + if u.app.session.page().unwrap() == page { + u.app.session.execute("page.select", &json!({"id": back})).unwrap(); + u.frame(vec![]); + // Rows are 32 high: aim at the middle, past the hit tolerance at its edge. + return p + vec2(0.0, 12.0); + } + } + panic!("no row for page {page:?}"); +} + +fn pages(u: &Ui) -> usize { + u.app.session.doc().unwrap().pages.len() +} + +#[test] +fn delete_after_clicking_a_page_deletes_it_without_asking() { + // #859: Delete on a page picked in the Pages list said "nothing is selected". + let (mut u, ..) = setup(); + let first = u.app.session.page().unwrap(); + let second = id(u.app.session.execute("page.add", &json!({})).unwrap()); + u.app.session.execute("page.select", &json!({"id": first})).unwrap(); + u.frame(vec![]); + let row = page_row(&mut u, second, first); + u.button(row, true); + u.button(row, false); + assert_eq!(u.app.session.page().unwrap(), second); + key(&mut u, Key::Delete, false); + // Gone at once, no confirmation: a toast says so and Undo brings it back. + assert_eq!(pages(&u), 1); + assert!(!u.app.session.doc().unwrap().pages.contains(&second)); + assert_eq!(u.app.ui.toast.as_ref().map(|t| t.0.as_str()), Some("Page deleted")); + u.app.run("edit.undo", json!({})); + assert_eq!(pages(&u), 2); + // Backspace (the Mac's delete key) does the same. + let row = page_row(&mut u, second, first); + u.button(row, true); + u.button(row, false); + key(&mut u, Key::Backspace, false); + assert_eq!(pages(&u), 1); + // The last page stays. + key(&mut u, Key::Delete, false); + assert_eq!(pages(&u), 1); +} + +#[test] +fn selected_layers_or_a_canvas_press_take_delete_back_from_the_pages_list() { + let (mut u, a, ..) = setup(); + let first = u.app.session.page().unwrap(); + let second = id(u.app.session.execute("page.add", &json!({})).unwrap()); + u.frame(vec![]); + let row = page_row(&mut u, first, second); + u.button(row, true); + u.button(row, false); + assert_eq!(u.app.session.page().unwrap(), first); + assert!(u.app.ui.pages_focus.is_some()); + // Layers selected after the click (Select all, say): Delete deletes them, not the page. + u.app.run("select.set", json!({"ids": [a]})); + key(&mut u, Key::Delete, false); + assert_eq!(pages(&u), 2); + assert!(u.app.session.doc().unwrap().node(a).is_none()); + // Nothing is selected now, but the list gave up the keyboard: a second Delete keeps the page. + assert!(u.app.ui.pages_focus.is_none()); + key(&mut u, Key::Delete, false); + assert_eq!(pages(&u), 2, "a second Delete keeps the page"); + // A press on the canvas gives Delete back to it. + u.button(row, true); + u.button(row, false); + assert!(u.app.ui.pages_focus.is_some()); + let p = pos2(900.0, 700.0); + u.button(p, true); + u.button(p, false); + assert!(u.app.ui.pages_focus.is_none()); + u.app.run("select.none", json!({})); + key(&mut u, Key::Delete, false); + assert_eq!(pages(&u), 2, "Delete with nothing selected on the canvas deletes no page"); +} + +#[test] +fn delete_deletes_no_page_once_the_pages_list_is_off_screen_or_another_file_is_open() { + let (mut u, ..) = setup(); + let first = u.app.session.page().unwrap(); + let second = id(u.app.session.execute("page.add", &json!({})).unwrap()); + u.app.session.execute("page.select", &json!({"id": first})).unwrap(); + u.frame(vec![]); + let row = page_row(&mut u, second, first); + let click = |u: &mut Ui| { + u.button(row, true); + u.button(row, false); + assert!(u.app.ui.pages_focus.is_some()); + }; + // The Assets tab, a collapsed Pages list, a collapsed left panel. + let hide: [fn(&mut Ui, bool); 3] = [ + |u, on| u.app.ui.left_tab = if on { crate::LeftTab::Assets } else { crate::LeftTab::File }, + |u, on| u.app.ui.pages_open = !on, + |u, on| u.app.ui.left_collapsed = on, + ]; + for (k, hide) in hide.iter().enumerate() { + click(&mut u); + hide(&mut u, true); + u.frame(vec![]); + key(&mut u, Key::Delete, false); + assert_eq!(pages(&u), 2, "case {k}: the list is off screen"); + hide(&mut u, false); + u.frame(vec![]); + } + // Another file open: its pages are safe. + click(&mut u); + u.app.run("file.new", json!({})); + u.app.run("page.add", json!({})); + u.frame(vec![]); + assert_eq!(pages(&u), 2); + key(&mut u, Key::Delete, false); + assert_eq!(pages(&u), 2, "the other file keeps its pages"); +} diff --git a/crates/ui-egui/src/layout_panel.rs b/crates/ui-egui/src/layout_panel.rs index 11d30369..b5ff1d45 100644 --- a/crates/ui-egui/src/layout_panel.rs +++ b/crates/ui-egui/src/layout_panel.rs @@ -401,35 +401,66 @@ fn alignment(app: &mut FigApp, ui: &mut Ui, ids: &[NodeId], l: &AutoLayout) { } } +/// The Spacing row: the H/V padding pair, or, with Individual padding on, two rows of one field +/// per side (like Individual corners). fn spacing(app: &mut FigApp, ui: &mut Ui, ids: &[NodeId], l: &AutoLayout) { let col = widgets::col_width(ui.available_width()); let [pt, pr, pb, pl] = l.padding; - row(ui, 32.0, |ui| { - let e = crate::variable_ui::number_field( - app, - ui, - ids, - "paddingHorizontal", - Id::new("al_padh"), - Lead::Icon(Icon::PaddingH, tr("Horizontal padding")), - (pl == pr).then_some(pl), - "", - col, - ); - app.field(ids, "paddingHorizontal", e, |v| json!(v.max(0.0))); - let e = crate::variable_ui::number_field( - app, - ui, - ids, - "paddingVertical", - Id::new("al_padv"), - Lead::Icon(Icon::PaddingV, tr("Vertical padding")), - (pt == pb).then_some(pt), - "", - col, - ); - app.field(ids, "paddingVertical", e, |v| json!(v.max(0.0))); - }); + let open = ui.data(|d| d.get_temp::(Id::new("indep_padding"))).unwrap_or(false); + let toggle = |ui: &mut Ui| { + if icons::button(ui, Icon::IndependentPadding, 24.0, open, tr("Individual padding")).clicked() { + ui.data_mut(|d| d.insert_temp(Id::new("indep_padding"), !open)); + } + }; + if open { + // `l` is the selection's common layout, so its sides are common too. `k` indexes + // [top, right, bottom, left]. + let sides = [ + ("paddingLeft", tr("Left padding"), Icon::PaddingLeft, pl, 3), + ("paddingTop", tr("Top padding"), Icon::PaddingTop, pt, 0), + ("paddingRight", tr("Right padding"), Icon::PaddingRight, pr, 1), + ("paddingBottom", tr("Bottom padding"), Icon::PaddingBottom, pb, 2), + ]; + for (r, pair) in sides.chunks(2).enumerate() { + row(ui, 32.0, |ui| { + for (key, name, icon, v, k) in pair { + let e = crate::variable_ui::number_field(app, ui, ids, key, Id::new(("padding", *k)), Lead::Icon(*icon, name), Some(*v), "", col); + app.field(ids, key, e, |v| json!(v.max(0.0))); + } + if r == 0 { + toggle(ui); + } + }); + } + } else { + row(ui, 32.0, |ui| { + let e = crate::variable_ui::number_field( + app, + ui, + ids, + "paddingHorizontal", + Id::new("al_padh"), + Lead::Icon(Icon::PaddingH, tr("Horizontal padding")), + (pl == pr).then_some(pl), + "", + col, + ); + app.field(ids, "paddingHorizontal", e, |v| json!(v.max(0.0))); + let e = crate::variable_ui::number_field( + app, + ui, + ids, + "paddingVertical", + Id::new("al_padv"), + Lead::Icon(Icon::PaddingV, tr("Vertical padding")), + (pt == pb).then_some(pt), + "", + col, + ); + app.field(ids, "paddingVertical", e, |v| json!(v.max(0.0))); + toggle(ui); + }); + } if l.mode != LayoutMode::Grid && l.wrap { // Wrapped rows (horizontal) or columns (vertical): the gap between them. let vertical = l.mode == LayoutMode::Vertical; diff --git a/crates/ui-egui/src/lib.rs b/crates/ui-egui/src/lib.rs index 450317b4..21510ba9 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; @@ -61,6 +62,7 @@ pub mod tabs; pub mod theme; pub mod toolbar; pub mod typography; +pub mod ui_scale; pub mod variable_ui; pub mod variables_panel; pub mod vector_panel; @@ -68,6 +70,8 @@ pub mod versions; pub mod widgets; pub mod windows; +#[cfg(test)] +mod board_ui_tests; #[cfg(test)] mod theme_startup_tests; @@ -86,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; @@ -146,8 +156,12 @@ mod live_tests; #[cfg(test)] mod live_text_tests; #[cfg(test)] +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; @@ -156,6 +170,8 @@ mod online_tests; #[cfg(test)] mod open_issues_tests; #[cfg(test)] +mod padding_individual_tests; +#[cfg(test)] mod palette_tests; #[cfg(test)] mod panel_remove_tests; @@ -178,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; @@ -204,10 +222,14 @@ mod text_enter_tests; #[cfg(test)] mod titlebar_tests; #[cfg(test)] +mod ui_scale_tests; +#[cfg(test)] mod variables_export_ui_tests; #[cfg(test)] mod variables_rename_tests; #[cfg(test)] +mod variants_discovery_tests; +#[cfg(test)] mod vector_panel_tests; #[cfg(test)] mod vector_tail_ui_tests; @@ -236,6 +258,11 @@ pub trait Services { fn pick_open(&mut self, _title: &str, _exts: &[&str]) -> DialogFuture { Box::pin(std::future::ready(None)) } + /// Pick a file and read it, where the platform hands over a file's name and contents rather + /// than a path to it (the browser). `None`: the platform picks paths ([`Services::pick_open`]). + fn pick_open_contents(&mut self, _title: &str, _exts: &[&str]) -> Option { + None + } fn pick_save(&mut self, _title: &str, _name: &str, _exts: &[&str]) -> DialogFuture { Box::pin(std::future::ready(None)) } @@ -290,14 +317,61 @@ pub trait Services { } pub type DialogFuture = Pin>>>; -type DialogDone = Box; +/// A picked file's name and contents ([`Services::pick_open_contents`]); `None` when the picker +/// was cancelled, `Err` with what to tell the user when the file couldn't be read. +pub type ContentsFuture = Pin), String>>>>>; +/// A dropped file's contents, read once they arrive (`Err`: what to tell the user when they +/// couldn't be read). +pub type ReadFuture = Pin, String>>>>; +type PickFuture = Pin>>>; +type DialogDone = Box; + +/// What a dialog picked: a path, or a file's name and contents where there are no paths, or why +/// the picked file couldn't be read. +enum Picked { + Path(PathBuf), + Contents(String, Vec), + Unreadable(String), +} + +impl Picked { + /// The file as `file.open` and `file.import` take it (`Err`: what to tell the user instead). + fn file_params(self) -> Result { + match self { + Picked::Path(p) => Ok(json!({"path": p.to_string_lossy()})), + Picked::Contents(name, bytes) => { + use base64::Engine as _; + Ok(json!({"data": base64::engine::general_purpose::STANDARD.encode(&bytes), "name": name})) + } + Picked::Unreadable(why) => Err(why), + } + } +} + +/// What the user is told about a file whose contents couldn't be read. +pub fn unreadable(name: &str) -> String { + i18n::trf("Couldn't read {name}.", &[("name", &name)]) +} + +/// The largest file read in the browser: wasm has at most 4 GiB of memory, and a file's contents +/// are held about three times on their way to `file.open` (the bytes, base64, decoded again). +/// As large as one member of a `.figcraft` file may be. +const MAX_BROWSER_FILE: f64 = (512u64 << 20) as f64; + +/// Checks a file of `size` bytes before the browser reads it (`Err`: what to tell the user). +pub fn browser_can_read(name: &str, size: f64) -> Result<(), String> { + if size.is_nan() || size > MAX_BROWSER_FILE { + return Err(i18n::trf("{name} is too large to open in the browser (over 512 MB).", &[("name", &name)])); + } + Ok(()) +} /// A native dialog in flight. The app keeps working while it is open, so its result goes to the /// file and window it was opened for, not to whichever is in front when it closes. struct PendingDialog { - future: DialogFuture, - /// The path picked, kept until the window that holds `doc` draws. - path: Option, + future: PickFuture, + /// What was picked, kept until the window that holds `doc` draws. + picked: Option, done: DialogDone, /// The file it acts on (`None`: none, such as Open). doc: Option, @@ -408,15 +482,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) } @@ -431,8 +506,13 @@ pub struct UiState { pub last_right_tab: RightTab, pub left_tab: LeftTab, pub pages_open: bool, + /// Pages panel search: open, and the case-insensitive name filter. + pub page_search: bool, + pub page_filter: String, /// The height asked for the pages list (`None`: a share of the panel). pub pages_height: Option, + /// The document and active page last drawn in the pages list. + pub pages_seen_page: Option<(u64, NodeId)>, pub expanded: BTreeSet, /// The layer being renamed in the layers panel. pub rename: Option, @@ -459,6 +539,9 @@ pub struct UiState { pub menu_input: Option, /// File ▸ Rename: the file name in the left panel starts its inline rename on the next frame. pub rename_file: bool, + /// What the interface size drew last frame and whether the window held it back. Set by + /// [`FigApp::logic`] for the main window. + pub ui_scale: ui_scale::Plan, pub shortcuts_panel: bool, /// The Keyboard shortcuts panel's tab and search text. pub shortcuts_tab: figcraft_engine::keymap::Group, @@ -475,10 +558,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, @@ -500,10 +583,16 @@ pub struct UiState { /// The layers panel has the keyboard: arrow keys move through its rows instead of nudging /// (set by a click on a row, cleared by a press on the canvas). pub layers_focus: bool, + /// The file (its `uid`) whose pages list has the keyboard: Delete with no layers selected deletes + /// its current page (set by a click on a page row or the add-page button, cleared by a click on a + /// layer row or a press on the canvas). + pub pages_focus: Option, pub layers_seen_sel: Vec, 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. @@ -558,6 +647,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, @@ -584,6 +676,9 @@ 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, } impl Default for UiState { @@ -595,7 +690,10 @@ impl Default for UiState { last_right_tab: RightTab::Design, left_tab: LeftTab::File, pages_open: true, + page_search: false, + page_filter: String::new(), pages_height: None, + pages_seen_page: None, expanded: BTreeSet::new(), rename: None, rename_page: None, @@ -612,6 +710,7 @@ impl Default for UiState { preferences: false, menu_input: None, rename_file: false, + ui_scale: ui_scale::plan(1.0, None, None), shortcuts_panel: false, shortcuts_tab: figcraft_engine::keymap::Group::Essential, shortcuts_query: String::new(), @@ -622,8 +721,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, @@ -634,10 +734,12 @@ impl Default for UiState { layer_filter: String::new(), layer_anchor: None, layers_focus: false, + pages_focus: None, layers_seen_sel: Vec::new(), dark: false, follow_system: false, tool_flyout: None, + board_panel: false, last_error: None, pending_opens: Vec::new(), pending_open_frames: 0, @@ -670,6 +772,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, @@ -682,6 +785,7 @@ impl Default for UiState { live: live::LiveUi::default(), online: online::OnlineUi::default(), confirm_close: false, + close_after_save_as: None, } } } @@ -758,9 +862,17 @@ impl FigApp { } } + /// Dismiss the composer after a successful switch away from the Comment tool. + pub(crate) fn clear_comment_draft_after_tool_set(&mut self, id: &str, succeeded: bool) { + if id == "tool.set" && succeeded && self.session.tool.tool != figcraft_engine::tools::ToolId::Comment { + self.ui.comments.draft = None; + } + } + /// Run a command, surfacing errors as a toast. Returns the result. pub fn run(&mut self, id: &str, params: Value) -> Option { let r = self.session.execute(id, ¶ms); + self.clear_comment_draft_after_tool_set(id, r.is_ok()); self.handle_ui_requests(); r.map_err(|e| self.failed(id, &e)).ok() } @@ -780,6 +892,11 @@ impl FigApp { fn failed(&mut self, id: &str, e: &figcraft_engine::EngineError) { let msg = e.to_string(); log::warn!("{id}: {msg}"); + self.show_error(msg); + } + + /// Show an error as a toast, kept as the last error. + pub(crate) fn show_error(&mut self, msg: String) { self.toast(msg.clone()); self.ui.last_error = Some(msg); } @@ -846,6 +963,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. @@ -890,7 +1023,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 }; @@ -918,7 +1059,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; @@ -939,12 +1080,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" => { @@ -1032,7 +1168,7 @@ impl FigApp { let resp = ui.interact(rect, id, egui::Sense::click_and_drag()); if resp.hovered() || resp.dragged() { ui.ctx().set_cursor_icon(egui::CursorIcon::ResizeHorizontal); - ui.painter().vline(x, rows, egui::Stroke::new(2.0, theme::Tokens::get(ui.ctx()).accent)); + ui.painter().vline(x, rows, egui::Stroke::new(2.0, theme::ACCENT)); } if resp.double_clicked() { return Some(PANEL_DEFAULT); @@ -1099,16 +1235,11 @@ impl FigApp { pub fn open_dialog(&mut self) { // Opening needs no file in front: it goes ahead even if that file closed meanwhile. - self.start_dialog( - |services| services.pick_open(i18n::tr("Open"), OPEN_EXTENSIONS), - false, - |app, p| { - let ps = p.to_string_lossy().to_string(); - if app.run_user("file.open", json!({"path": ps})).is_some() { - app.ui.home = false; - } - }, - ); + self.pick_file(i18n::tr("Open"), OPEN_EXTENSIONS, false, |app, file| { + if app.run_user("file.open", file).is_some() { + app.ui.home = false; + } + }); } pub fn import_dialog(&mut self, place: bool) { @@ -1118,12 +1249,35 @@ impl FigApp { &["svg", "png", "jpg", "jpeg", "webp", "gif", "tif", "tiff", "figcraft", "fig"] }; let title = if place { i18n::tr("Place image") } else { i18n::tr("Import") }; - self.pick_open(title, exts, move |app, p| { - let ps = p.to_string_lossy().to_string(); - app.run_user("file.import", json!({"path": ps})); + self.pick_file(title, exts, true, |app, file| { + app.run_user("file.import", file); }); } + /// Pick a file to open or import; `done` gets it as `file.open` and `file.import` take it (a + /// path, or the name and contents where the platform has no paths). `pin`: as + /// [`FigApp::pick_open`]'s file in front. + fn pick_file(&mut self, title: &str, exts: &[&str], pin: bool, done: impl FnOnce(&mut FigApp, Value) + 'static) { + self.start_pick( + |services| -> PickFuture { + match services.pick_open_contents(title, exts) { + Some(read) => { + Box::pin(async move { read.await.map(|r| r.map_or_else(Picked::Unreadable, |(name, bytes)| Picked::Contents(name, bytes))) }) + } + None => { + let pick = services.pick_open(title, exts); + Box::pin(async move { pick.await.map(Picked::Path) }) + } + } + }, + pin, + move |app, picked| match picked.file_params() { + Ok(file) => done(app, file), + Err(why) => app.show_error(why), + }, + ); + } + /// Pick a file to open. `done` runs with the file in front now back in front (and not at all /// if that file closed meanwhile). pub fn pick_open(&mut self, title: &str, exts: &[&str], done: impl FnOnce(&mut FigApp, PathBuf) + 'static) { @@ -1142,12 +1296,28 @@ impl FigApp { /// Open a native dialog from the window drawing now, for the file in front (`pin`). fn start_dialog(&mut self, start: impl FnOnce(&mut dyn Services) -> DialogFuture, pin: bool, done: impl FnOnce(&mut FigApp, PathBuf) + 'static) { + self.start_pick( + |services| -> PickFuture { + let pick = start(services); + Box::pin(async move { pick.await.map(Picked::Path) }) + }, + pin, + move |app, picked| { + if let Picked::Path(p) = picked { + done(app, p); + } + }, + ); + } + + /// [`FigApp::start_dialog`] for any kind of pick. + fn start_pick(&mut self, start: impl FnOnce(&mut dyn Services) -> PickFuture, pin: bool, done: impl FnOnce(&mut FigApp, Picked) + 'static) { if self.pending_dialog.is_some() { return; } let doc = if pin { self.session.ds().ok().filter(|d| self.shows(d.uid)).map(|d| d.uid) } else { None }; let future = start(self.services.as_mut()); - self.pending_dialog = Some(PendingDialog { future, path: None, done: Box::new(done), doc, window: self.windows.current }); + self.pending_dialog = Some(PendingDialog { future, picked: None, done: Box::new(done), doc, window: self.windows.current }); if let Some(ctx) = &self.ui_context { ctx.request_repaint(); } @@ -1157,12 +1327,12 @@ impl FigApp { /// that file in front; then the tab in front before comes back (unless the dialog opened a file). fn poll_dialog(&mut self, ctx: &egui::Context) { let Some(d) = &mut self.pending_dialog else { return }; - if d.path.is_none() { + if d.picked.is_none() { let waker = Waker::from(Arc::new(RepaintWaker(ctx.clone()))); - let Poll::Ready(path) = d.future.as_mut().poll(&mut TaskContext::from_waker(&waker)) else { return }; - d.path = path; + let Poll::Ready(picked) = d.future.as_mut().poll(&mut TaskContext::from_waker(&waker)) else { return }; + d.picked = picked; } - let (doc, origin, picked) = (d.doc, d.window, d.path.is_some()); + let (doc, origin, picked) = (d.doc, d.window, d.picked.is_some()); let index = doc.map(|u| self.session.docs.iter().position(|ds| ds.uid == u)); // Cancelled, or its file closed meanwhile. if !picked || index == Some(None) { @@ -1178,12 +1348,12 @@ impl FigApp { ctx.request_repaint_of(windows::viewport(window)); return; } - let Some(PendingDialog { path: Some(path), done, .. }) = self.pending_dialog.take() else { return }; + let Some(PendingDialog { picked: Some(picked), done, .. }) = self.pending_dialog.take() else { return }; let before = self.session.ds().ok().map(|d| d.uid); if let Some(Some(i)) = index { self.session.active = i; } - done(self, path); + done(self, picked); if doc.is_some() && self.session.ds().ok().map(|d| d.uid) == doc && let Some(i) = before.and_then(|u| self.session.docs.iter().position(|d| d.uid == u)) @@ -1197,11 +1367,11 @@ impl FigApp { self.session.prefs.reduce_motion || self.services.reduce_motion() } - /// The CJK scripts the UI shows: the language's, the language picker's (Preferences open), + /// The CJK and Bengali scripts the UI shows: the language's, the language picker's (Preferences open), /// typed text's, and those in the open file's names and comments (read when it changes, not /// mid-drag). Nothing is checked once every script has a face. fn cjk_needed(&mut self, ctx: &egui::Context, installed: u8) -> u8 { - if installed == cjk::KANA | cjk::HANGUL | cjk::HAN { + if installed == cjk::ALL { return installed; } let mut need = i18n::current().map_or(0, |l| cjk::language(l.code)); @@ -1261,6 +1431,16 @@ impl FigApp { theme::install_fonts(ctx, true, cjk); } } + // egui's zoom is for the whole app and multiplies each window's own system scale, so the + // size is the same relative to every screen; the main window, whose size can hold a size + // back, sets it. The browser keeps its own zoom. + if ctx.viewport_id() == egui::ViewportId::ROOT { + let window_px = ctx.content_rect().size() * ctx.pixels_per_point(); + let size = if cfg!(target_arch = "wasm32") { 1.0 } else { self.session.prefs.ui_scale }; + let plan = ui_scale::plan(size, ctx.native_pixels_per_point(), Some((window_px.x, window_px.y))); + ctx.set_zoom_factor(plan.zoom); + self.ui.ui_scale = plan; + } let dark = match self.session.prefs.theme.as_str() { "dark" => true, // The active visuals were set by us last frame (possibly for a manual theme). @@ -1390,7 +1570,7 @@ impl FigApp { // What happened in a live session (the network woke us for it). if self.session.live_poll() { if let Some(m) = self.session.message.take() { - self.toast(m); + self.toast(live::failure_text(&m)); } self.background_done(&ctx); } @@ -1415,6 +1595,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(); @@ -1440,9 +1630,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 @@ -1467,6 +1657,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) @@ -1479,7 +1670,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 { @@ -1498,7 +1689,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) @@ -1629,7 +1820,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, @@ -1637,7 +1828,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()), @@ -1679,8 +1870,11 @@ impl FigApp { "importReport": self.ui.import_report.as_ref().map(import_report::Report::inspect), }); state["homeSearch"] = json!(self.ui.home_view.file_query); + // Set apart from the literal above, which is at the macro recursion limit. + 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 } } @@ -1693,17 +1887,28 @@ 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))) } -/// The contents of a dropped file that has no file on disk to read by path. -#[cfg(not(target_arch = "wasm32"))] -pub(crate) fn dropped_bytes(f: &egui::DroppedFileHandle) -> Option> { - f.bytes().ok() +/// 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))) } -/// The contents of a dropped file that has no file on disk to read by path. +/// Reads the contents of dropped file `name` that has no file on disk to read by path. +#[cfg(not(target_arch = "wasm32"))] +pub(crate) fn dropped_bytes(f: &egui::DroppedFileHandle, name: &str) -> ReadFuture { + Box::pin(std::future::ready(f.bytes().map_err(|_| unreadable(name)))) +} + +/// Reads the contents of dropped file `name`; the browser hands them over asynchronously. #[cfg(target_arch = "wasm32")] -pub(crate) fn dropped_bytes(_f: &egui::DroppedFileHandle) -> Option> { - // Browser drops arrive asynchronously; not supported yet. - None +pub(crate) fn dropped_bytes(f: &egui::DroppedFileHandle, name: &str) -> ReadFuture { + let (f, name) = (f.clone(), name.to_string()); + Box::pin(async move { + if let Some(file) = f.web_file() { + browser_can_read(&name, file.size())?; + } + f.bytes_async().await.map_err(|_| unreadable(&name)) + }) } /// While files are dragged over the window: a tinted, outlined `rect` with `label` in its middle, @@ -1716,11 +1921,11 @@ pub(crate) fn drop_overlay(ctx: &egui::Context, rect: egui::Rect, label: &str) { let painter = ctx.layer_painter(egui::LayerId::new(egui::Order::Foreground, egui::Id::new("drop_overlay"))); let area = rect.shrink(4.0); painter.rect_filled(area, egui::CornerRadius::same(8), t.accent.gamma_multiply(0.12)); - painter.rect_stroke(area, egui::CornerRadius::same(8), egui::Stroke::new(2.0, t.accent), egui::StrokeKind::Inside); + painter.rect_stroke(area, egui::CornerRadius::same(8), egui::Stroke::new(2.0, theme::ACCENT), egui::StrokeKind::Inside); let galley = painter.layout_no_wrap(label.to_owned(), theme::semibold(14.0), t.text); let pill = egui::Rect::from_center_size(area.center(), galley.size() + egui::vec2(32.0, 16.0)); painter.rect_filled(pill, egui::CornerRadius::same(8), t.panel); - painter.rect_stroke(pill, egui::CornerRadius::same(8), egui::Stroke::new(1.0, t.accent), egui::StrokeKind::Inside); + painter.rect_stroke(pill, egui::CornerRadius::same(8), egui::Stroke::new(1.0, theme::ACCENT), egui::StrokeKind::Inside); painter.galley(pill.center() - galley.size() / 2.0, galley, t.text); // Hovering sends no pointer events on every platform: keep the overlay current. ctx.request_repaint(); @@ -1729,5 +1934,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.rs b/crates/ui-egui/src/live.rs index 513060ab..a4baa303 100644 --- a/crates/ui-egui/src/live.rs +++ b/crates/ui-egui/src/live.rs @@ -13,10 +13,11 @@ //! and it fades a few seconds after the last change (frames are asked for only until then). //! Spotlight: your avatar's menu asks everyone to follow you; a banner says so and stops it. +use std::borrow::Cow; use std::collections::BTreeMap; use egui::{Align2, Color32, CornerRadius, Pos2, Rect, Sense, Shape, Stroke, Ui, pos2, vec2}; -use figcraft_engine::live::{Access, PEOPLE, Role, SessionId, Share, Signal, Status, Via, initials, parse_color, presence_page}; +use figcraft_engine::live::{Access, PEOPLE, Role, SessionId, Share, Signal, Status, TOO_LARGE_TO_JOIN, Via, initials, parse_color, presence_page}; use serde_json::{Value, json}; use crate::FigApp; @@ -595,7 +596,7 @@ pub fn share_popover(app: &mut FigApp, anchor: &egui::Response) { (Status::Reconnecting { attempt }, _) => { (trf("Connection lost, reconnecting (attempt {attempt})…", &[("attempt", attempt)]), t.text_secondary) } - (Status::Failed { .. }, _) => (status.label(), t.danger), + (Status::Failed { reason }, _) => (failure_text(reason).into_owned(), t.danger), _ => (tr(&status.label()).to_string(), t.text_secondary), }; if !line.is_empty() { @@ -695,11 +696,18 @@ pub fn join_field(app: &mut FigApp, ui: &mut Ui, typed: &mut String, id: egui::I pub fn join_status(app: &FigApp, ui: &mut Ui, t: &Tokens, session: SessionId) { match app.session.live.get(session).map(|l| &l.status) { Some(Status::Connecting) => widgets::label(ui, tr("Joining…"), t.text_secondary), - Some(Status::Failed { reason }) => widgets::label(ui, reason, t.danger), + Some(Status::Failed { reason }) => widgets::label(ui, &failure_text(reason), t.danger), _ => {} } } +/// Why a live session failed, in the UI language when it is a reason the UI knows (the transport's +/// English otherwise). +pub(crate) fn failure_text(reason: &str) -> Cow<'_, str> { + let mb = TOO_LARGE_TO_JOIN.split_once("{mb}").and_then(|(before, after)| reason.strip_prefix(before)?.strip_suffix(after)); + mb.map_or(Cow::Borrowed(reason), |mb| Cow::Owned(trf(TOO_LARGE_TO_JOIN, &[("mb", &mb)]))) +} + /// Not in a session: your name, how to share (online or on the local network), start; or join /// one with a code. Everything else is under Advanced. fn start_ui(app: &mut FigApp, ui: &mut Ui, t: &Tokens) { 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/measure_nudge_tests.rs b/crates/ui-egui/src/measure_nudge_tests.rs new file mode 100644 index 00000000..b4a5a247 --- /dev/null +++ b/crates/ui-egui/src/measure_nudge_tests.rs @@ -0,0 +1,64 @@ +//! Nudging while measuring (#894), driven through egui headless: with Alt held over another layer +//! the arrows still nudge the selection (Shift: the big step), the distance follows every nudge, +//! and the selection's handles and size label hide while measuring (its outline stays). + +use egui::{Event, Key, Modifiers, Pos2, Shape}; +use figcraft_doc::NodeId; +use figcraft_engine::Session; +use serde_json::json; + +use crate::layers_drag_tests::Ui; +use crate::live_tests::flat; +use crate::present_tests::id; + +fn labels(u: &Ui) -> Vec { + u.app.session.tool.overlay.guides.iter().filter_map(|g| g.label.clone()).collect() +} + +fn top(u: &Ui, i: NodeId) -> f64 { + u.app.session.doc().unwrap().world_bounds(i).unwrap().y0 +} + +/// Press and release `key` with `m` held, then a settled frame (the pointer still over the canvas). +fn press(u: &mut Ui, key: Key, m: Modifiers, at: Pos2) { + let ev = |pressed| Event::Key { key, physical_key: None, pressed, repeat: false, modifiers: m }; + u.frame_with(vec![ev(true), ev(false)], m); + u.frame_with(vec![Event::PointerMoved(at)], m); +} + +/// Whether the frame drew corner handles or a text `label`. +fn drew(shapes: &[Shape], label: &str) -> (bool, bool) { + let handle = shapes.iter().any(|s| matches!(s, Shape::Rect(r) if (r.rect.width() - 8.0).abs() < 0.1 && (r.rect.height() - 8.0).abs() < 0.1)); + let text = shapes.iter().any(|s| matches!(s, Shape::Text(t) if t.galley.text() == label)); + (handle, text) +} + +#[test] +fn arrows_nudge_while_measuring_and_the_distance_follows() { + let mut s = Session::new(); + let upper = id(&s.execute("shape.rectangle", &json!({"x": 0, "y": 0, "width": 100, "height": 40})).unwrap()); + let lower = id(&s.execute("shape.rectangle", &json!({"x": 0, "y": 140, "width": 100, "height": 40})).unwrap()); + s.execute("select.set", &json!({"ids": [lower]})).unwrap(); + let mut u = Ui::new(s); + let over = u.app.to_screen(50.0, 20.0); + let (alt, shift_alt) = (Modifiers { alt: true, ..Modifiers::NONE }, Modifiers { alt: true, shift: true, ..Modifiers::NONE }); + + // Not measuring: the selection shows its handles and size. + let shapes = flat(&u.frame_with(vec![Event::PointerMoved(over)], Modifiers::NONE).shapes); + assert_eq!(drew(&shapes, "100 × 40"), (true, true), "handles and size label show"); + + u.frame_with(vec![Event::PointerMoved(over)], alt); + let shapes = flat(&u.frame_with(vec![Event::PointerMoved(over)], alt).shapes); + assert!(u.app.session.tool.overlay.measure_box.is_some(), "Alt over the other layer measures to it"); + assert!(labels(&u).contains(&"100".to_string()), "the gap: {:?}", labels(&u)); + assert_eq!(drew(&shapes, "100 × 40"), (false, false), "measuring hides the handles and size label"); + + press(&mut u, Key::ArrowUp, alt, over); + assert_eq!(top(&u, lower), 139.0, "Alt+Up nudges by the small step"); + assert!(labels(&u).contains(&"99".to_string()), "the gap follows the nudge: {:?}", labels(&u)); + + press(&mut u, Key::ArrowUp, shift_alt, over); + assert_eq!(top(&u, lower), 129.0, "Shift+Alt+Up nudges by the big step"); + assert!(labels(&u).contains(&"89".to_string()), "the gap follows the big nudge: {:?}", labels(&u)); + assert_eq!(top(&u, upper), 0.0, "the measured layer stays put"); +} 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 e32f50c8..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 { @@ -112,12 +139,34 @@ pub fn record_recent(app: &mut FigApp, key: &str) { r.truncate(figcraft_engine::MAX_RECENT_ACTIONS); } +/// 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). "edit.delete" if app.ui_context.clone().is_some_and(|ctx| crate::proto_panel::delete_selected_interaction(app, &ctx)) => {} + // After a click in the Pages list, while the list is on screen and its file open, with no + // layers selected, it deletes the current page (#859). + "edit.delete" if crate::layers::pages_have_keyboard(app) && app.session.selection().is_empty() => { + if let Ok(page) = app.session.page() { + crate::layers::delete_page(app, page); + } + } + // Otherwise the list gives up the keyboard: once Delete has taken layers selected since the + // click (Select all, say), a second Delete must not take the page. + "edit.delete" if app.ui.pages_focus.is_some() => { + app.ui.pages_focus = None; + 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), @@ -206,12 +255,13 @@ pub fn invoke(app: &mut FigApp, id: &str) { "collab.publish" => crate::live::open_publish(app), "edit.copy" | "edit.cut" => { let ids = app.session.selection(); - // SVG for other apps, taken before a cut removes the layers. - let svg = app.session.doc().ok().and_then(|d| figcraft_svg_export(d, &ids)); + // For other apps: text layers' text, other layers as SVG; taken before a cut removes them. + let doc = app.session.doc().ok(); + let mirror = doc.and_then(|d| text_layers_text(d, &ids).or_else(|| figcraft_svg_export(d, &ids))); app.run(id, json!({})); - // Mirror to the system clipboard (SVG text, else a marker) and remember it, so a later - // paste knows whether the clipboard still holds our layers. - let text = svg.unwrap_or_else(|| format!("figcraft://clipboard/{}", ids.len())); + // Mirror to the system clipboard (text or SVG, else a marker) and remember it, so a + // later paste knows whether the clipboard still holds our layers. + let text = mirror.unwrap_or_else(|| format!("figcraft://clipboard/{}", ids.len())); app.services.clipboard_set_text(&text); app.ui.last_copy = Some(text); } @@ -239,6 +289,14 @@ fn export_frames_pdf(app: &mut FigApp) { }); } +/// The text of a selection made only of text layers, one layer per line (None for any other +/// selection, or when there is no text). +fn text_layers_text(doc: &figcraft_doc::Document, ids: &[figcraft_doc::NodeId]) -> Option { + let texts: Option> = ids.iter().map(|i| doc.node(*i).and_then(|n| n.kind.text()).map(|t| t.characters.as_str())).collect(); + texts.map(|t| t.join("\n")).filter(|t| !t.is_empty()) +} + +/// The layers as SVG text for other apps (None when the export fails). fn figcraft_svg_export(doc: &figcraft_doc::Document, ids: &[figcraft_doc::NodeId]) -> Option { let bytes = figcraft_engine::cmd::file::export_bytes(doc, ids, figcraft_doc::ExportFormat::Svg, 1.0).ok()?; String::from_utf8(bytes).ok() @@ -829,6 +887,14 @@ pub fn popups(app: &mut FigApp, ctx: &egui::Context) { None } }); + // Two or more main components combine into a component set. Judged on the selection + // the command acts on: a layer row right-clicked in a multi-selection passes only itself. + let combine = app + .session + .doc() + .ok() + .filter(|doc| figcraft_engine::cmd::component::can_combine_variants(doc, &app.session.selection())) + .map(|_| i18n::command("component.combineVariants", "Combine as variants")); let mut entries: Vec<(&str, &str)> = if has_sel { vec![ (tr("Copy"), "edit.copy"), @@ -886,6 +952,11 @@ pub fn popups(app: &mut FigApp, ctx: &egui::Context) { { entries.insert(at + 1, entry); } + if let Some(label) = &combine + && let Some(at) = entries.iter().position(|e| e.1 == "component.create") + { + entries.insert(at + 1, (label, "component.combineVariants")); + } // A right-clicked ruler guide (selected, with no layers) can be removed. if !has_sel && app.session.ds().is_ok_and(|d| d.selected_guide().is_some()) { entries.splice(0..0, [(tr("Remove guide"), "guide.remove"), ("-", "")]); @@ -995,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/padding_individual_tests.rs b/crates/ui-egui/src/padding_individual_tests.rs new file mode 100644 index 00000000..bc95886e --- /dev/null +++ b/crates/ui-egui/src/padding_individual_tests.rs @@ -0,0 +1,114 @@ +//! Individual padding in the Design panel, driven headless: the four side fields only +//! appear once the toggle opens, and typing in one sets that side alone. + +use egui::accesskit::Role; +use egui::{Event, Id, Key, Modifiers, Pos2, pos2}; +use figcraft_doc::NodeId; +use figcraft_engine::Session; +use serde_json::json; + +use crate::layers_drag_tests::Ui; + +/// An auto layout frame with uniform padding 10, selected. +fn scene() -> (Ui, NodeId) { + let mut s = Session::new(); + let f = + NodeId( + s.execute("frame.create", &json!({"x": 0, "y": 0, "width": 300, "height": 200, "props": {"layout": "horizontal", "padding": 10}})) + .unwrap()["id"] + .as_u64() + .unwrap(), + ); + s.execute("select.set", &json!({"ids": [f]})).unwrap(); + (Ui::new(s), f) +} + +/// The frame's padding as `[top, right, bottom, left]`. +fn padding(u: &Ui, f: NodeId) -> [f64; 4] { + u.app.session.doc().unwrap().node(f).unwrap().layout.clone().unwrap().padding +} + +/// 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) +} + +/// The side fields stay hidden until the toggle opens, then typing in one sets +/// that side alone in a single undo step. +#[test] +fn side_fields_show_only_once_the_toggle_opens_and_set_one_side() { + let (mut u, f) = scene(); + assert_eq!(padding(&u, f), [10.0; 4]); + // Shut: no per-side fields. + assert!(u.ctx.read_response(Id::new(("padding", 0)).with("field")).is_none(), "top field hidden until toggled"); + // Click the toggle: the four sides appear. + let t = named_button(&mut u, "Individual padding"); + click(&mut u, t); + let top = u.ctx.read_response(Id::new(("padding", 0)).with("field")).expect("top field shows").rect.center(); + assert!(u.ctx.read_response(Id::new(("padding", 3)).with("field")).is_some(), "left field shows"); + // Typing in the top field sets only the top, in one undo step. + let history = u.app.session.undo_depth().0; + click(&mut u, top); + u.frame(vec![Event::Text("8".into())]); + enter(&mut u); + u.frame(vec![]); + assert_eq!(padding(&u, f), [8.0, 10.0, 10.0, 10.0]); + assert_eq!(u.app.session.undo_depth().0, history + 1); + u.app.run("edit.undo", json!({})); + assert_eq!(padding(&u, f), [10.0; 4]); +} + +/// The middle of the shut pair field with accessible name `label`. +fn named_field(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) +} + +/// The shut pair fields read Mixed when their sides disagree and edit their own axis: horizontal +/// touches left and right only, vertical touches top and bottom only. +#[test] +fn shut_pair_fields_edit_their_own_axis() { + let (mut u, f) = scene(); + u.app.run("node.set", json!({"ids": [f], "props": {"paddingLeft": 16.0, "paddingRight": 54.0}})); + assert_eq!(padding(&u, f), [10.0, 54.0, 10.0, 16.0]); + let shown = crate::live_tests::texts(&crate::live_tests::flat(&u.frame_output(vec![]).shapes)); + assert!(shown.iter().any(|(t, _, _)| t == "Mixed"), "sides that disagree read Mixed: {shown:?}"); + let h = named_field(&mut u, "Horizontal padding"); + click(&mut u, h); + u.frame(vec![Event::Text("20".into())]); + enter(&mut u); + u.frame(vec![]); + assert_eq!(padding(&u, f), [10.0, 20.0, 10.0, 20.0]); + let v = named_field(&mut u, "Vertical padding"); + click(&mut u, v); + u.frame(vec![Event::Text("7".into())]); + enter(&mut u); + u.frame(vec![]); + assert_eq!(padding(&u, f), [7.0, 20.0, 7.0, 20.0]); +} 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/pending_open.rs b/crates/ui-egui/src/pending_open.rs index 9d19b942..f5e978c6 100644 --- a/crates/ui-egui/src/pending_open.rs +++ b/crates/ui-egui/src/pending_open.rs @@ -1,13 +1,17 @@ //! Files dropped on the home screen or the canvas open a frame later, behind a card that says what //! is happening: opening a large `.fig` blocks the UI thread for a while, and the card is on screen -//! before it does (rather than a window that freezes without a word). +//! before it does (rather than a window that freezes without a word). A file with no path to read +//! (every drop in the browser) waits behind the card until its contents arrive. + +use std::sync::Arc; +use std::task::{Context as TaskContext, Poll, Waker}; use figcraft_doc::NodeId; -use serde_json::Value; +use serde_json::{Value, json}; -use crate::FigApp; use crate::i18n::{tr, trf, trn}; use crate::theme::{Tokens, semibold}; +use crate::{FigApp, ReadFuture, RepaintWaker}; /// A dropped file waiting to be opened (`file.open`, a document of its own) or imported /// (`file.import`, into the current document). @@ -18,6 +22,8 @@ pub struct PendingOpen { pub command: &'static str, /// Canvas imports stay with the document and page that received the drop. pub target: Option, + /// The file's contents still on their way; they go into `params` as `data` once read. + pub read: Option, } pub struct ImportTarget { @@ -26,6 +32,16 @@ pub struct ImportTarget { pub name: String, } +/// Queues dropped file `f` to open as a document of its own: by path, or by its contents when there +/// is no file on disk to read. Callers checked its extension, so there is a name to tell the format by. +pub(crate) fn open_dropped(app: &mut FigApp, f: &egui::DroppedFileHandle) { + let path = f.path(); + let name = path.file_name().map(|n| n.to_string_lossy().to_string()).unwrap_or_default(); + let (params, read) = + if path.exists() { (json!({"path": path.to_string_lossy()}), None) } else { (json!({"name": name}), Some(crate::dropped_bytes(f, &name))) }; + app.ui.pending_opens.push(PendingOpen { label: name, params, command: "file.open", target: None, read }); +} + fn run_pending(app: &mut FigApp, pending: PendingOpen) -> Option { let target = match pending.target { Some(target) => target, @@ -66,33 +82,83 @@ fn run_pending(app: &mut FigApp, pending: PendingOpen) -> Option { /// An import that has no destination left: nothing is imported, and the user is told why. fn cancel(app: &mut FigApp, message: String) { - app.ui.last_error = Some(message.clone()); - app.toast(message); + app.show_error(message); } +/// Space between files dropped together on the canvas. +const IMPORT_GAP: f64 = 20.0; + /// Frames the card is drawn before the files open: egui lays a new card out invisibly on its first /// frame, so the second one is the first the window shows. const FRAMES_BEFORE_OPENING: u8 = 2; -/// The card while files wait, then (once it has been on screen) open them. Called before the import -/// report, so a report for these files shows after them. +/// Polls the contents still being read: those that arrived go into their file's parameters, files +/// that couldn't be read are dropped with a message. Whether any are still on their way. +fn read_contents(app: &mut FigApp, ctx: &egui::Context) -> bool { + let waker = Waker::from(Arc::new(RepaintWaker(ctx.clone()))); + let mut cx = TaskContext::from_waker(&waker); + let mut reading = false; + let mut unread = Vec::new(); + app.ui.pending_opens.retain_mut(|pending| { + let Some(read) = pending.read.as_mut() else { return true }; + match read.as_mut().poll(&mut cx) { + Poll::Pending => { + reading = true; + true + } + Poll::Ready(Ok(bytes)) => { + use base64::Engine as _; + pending.params["data"] = json!(base64::engine::general_purpose::STANDARD.encode(&bytes)); + pending.read = None; + true + } + Poll::Ready(Err(why)) => { + unread.push(why); + false + } + } + }); + for why in unread { + app.show_error(why); + } + reading +} + +/// The card while files wait, then (once it has been on screen and their contents are read) open +/// them. Called before the import report, so a report for these files shows after them. pub fn pending_opens(app: &mut FigApp, ctx: &egui::Context) { + let reading = read_contents(app, ctx); if app.ui.pending_opens.is_empty() { app.ui.pending_open_frames = 0; return; } ctx.request_repaint(); - if app.ui.pending_open_frames >= FRAMES_BEFORE_OPENING { + if app.ui.pending_open_frames >= FRAMES_BEFORE_OPENING && !reading { app.ui.pending_open_frames = 0; - for pending in std::mem::take(&mut app.ui.pending_opens) { + // Files dropped together land side by side: each starts where the previous one ends. + let mut next_x: Option = None; + for mut pending in std::mem::take(&mut app.ui.pending_opens) { let command = pending.command; - if run_pending(app, pending).is_some() && command == "file.open" { + let canvas = command == "file.import" && pending.params.get("x").is_some(); + if canvas && let Some(x) = next_x { + pending.params["x"] = json!(x); + } + let page = pending.target.as_ref().map(|t| (t.uid, t.page)); + let result = run_pending(app, pending); + if result.is_some() && command == "file.open" { app.ui.home = false; } + if canvas { + let right = result.as_ref().and_then(|r| r.get("id")?.as_u64()).and_then(|id| { + let doc = &app.session.docs.iter().find(|d| Some(d.uid) == page.map(|p| p.0))?.doc; + doc.world_bounds(NodeId(id)).map(|b| b.x1) + }); + next_x = right.map(|r| r + IMPORT_GAP).or(next_x); + } } return; } - app.ui.pending_open_frames += 1; + app.ui.pending_open_frames = app.ui.pending_open_frames.saturating_add(1); let title = match app.ui.pending_opens.first().map(|p| p.command) { Some("file.import") => { let name = app 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 36eceb86..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 @@ -2223,7 +2268,7 @@ fn top_bar(p: &mut Presenter, ui: &mut Ui, bar: Rect, now: f64, chrome: Chrome) } let on = (*act == BarAction::Comments && p.view.comments) || (*act == BarAction::Inspect && p.view.inspect); if on { - ui.painter().rect_filled(br, CornerRadius::same(5), ACCENT); + ui.painter().rect_filled(br, CornerRadius::same(5), t.accent); } else if resp.hovered() { ui.painter().rect_filled(br, CornerRadius::same(5), hover); } @@ -2234,9 +2279,9 @@ fn top_bar(p: &mut Presenter, ui: &mut Ui, bar: Rect, now: f64, chrome: Chrome) resp = resp.tip(tr("Inspect the layer under the pointer: size, colours, typography")); } if chrome.pane { - icons::paint(ui.painter(), br.shrink(5.0), *icon, t.icon); + icons::paint(ui.painter(), br.shrink(5.0), *icon, if on { t.accent_on } else { t.icon }); } else { - ui.painter().text(br.center(), Align2::CENTER_CENTER, shown, regular(11.5), text); + ui.painter().text(br.center(), Align2::CENTER_CENTER, shown, regular(11.5), if on { t.accent_on } else { text }); } if *act == BarAction::Options { let menu = crate::right::menu_popup(&resp).width(220.0).show(|ui| options_menu(p, ui, device, settings.responsive, now)); @@ -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/proto_canvas.rs b/crates/ui-egui/src/proto_canvas.rs index 1187944d..956523ee 100644 --- a/crates/ui-egui/src/proto_canvas.rs +++ b/crates/ui-egui/src/proto_canvas.rs @@ -8,7 +8,6 @@ use egui::{Color32, CornerRadius, Id, Pos2, Rect, Shape, Stroke, StrokeKind, pos use figcraft_doc::{Document, NodeId}; use serde_json::{Value, json}; -use crate::theme::ACCENT; use crate::{FigApp, RightTab}; const HANDLE_R: f32 = 7.0; @@ -140,7 +139,7 @@ fn arrow(painter: &egui::Painter, pts: &[Pos2; 4], col: Color32) { } fn plus_handle(painter: &egui::Painter, c: Pos2, hot: bool) { - painter.circle_filled(c, HANDLE_R + if hot { 1.0 } else { 0.0 }, ACCENT); + painter.circle_filled(c, HANDLE_R + if hot { 1.0 } else { 0.0 }, crate::theme::canvas_accent(painter.ctx())); painter.circle_stroke(c, HANDLE_R + if hot { 1.0 } else { 0.0 }, Stroke::new(1.5, Color32::WHITE)); painter.line_segment([c - vec2(3.5, 0.0), c + vec2(3.5, 0.0)], Stroke::new(1.5, Color32::WHITE)); painter.line_segment([c - vec2(0.0, 3.5), c + vec2(0.0, 3.5)], Stroke::new(1.5, Color32::WHITE)); @@ -161,7 +160,11 @@ pub fn draw(app: &FigApp, painter: &egui::Painter) { let selected = sel.contains(&nd.source); let is_open = open == Some((nd.source, nd.index)); let near = dragging.is_none() && hover.is_some_and(|q| bezier_distance(&nd.pts, q) < 5.0); - let col = if selected || is_open || near { ACCENT } else { ACCENT.gamma_multiply(0.55) }; + let col = if selected || is_open || near { + crate::theme::canvas_accent(painter.ctx()) + } else { + crate::theme::canvas_accent(painter.ctx()).gamma_multiply(0.55) + }; let w = if is_open || near { 2.5 } else if selected { @@ -177,7 +180,12 @@ pub fn draw(app: &FigApp, painter: &egui::Painter) { .and_then(|it| figcraft_doc::proto::walk(&it.actions).find_map(|a| a.dest())) && let Some(r) = screen_rect(app, doc, dest) { - painter.rect_stroke(r, CornerRadius::ZERO, Stroke::new(1.0, ACCENT.gamma_multiply(0.7)), StrokeKind::Outside); + painter.rect_stroke( + r, + CornerRadius::ZERO, + Stroke::new(1.0, crate::theme::canvas_accent(painter.ctx()).gamma_multiply(0.7)), + StrokeKind::Outside, + ); } painter.add(egui::epaint::CubicBezierShape::from_points_stroke(nd.pts, false, Color32::TRANSPARENT, Stroke::new(w, col))); painter.circle_filled(nd.pts[0], 3.5, col); @@ -190,12 +198,17 @@ pub fn draw(app: &FigApp, painter: &egui::Painter) { && doc.top_level_frame(d.source) != Some(f) && let Some(r) = screen_rect(app, doc, f) { - painter.rect_stroke(r, CornerRadius::ZERO, Stroke::new(2.0, ACCENT), StrokeKind::Outside); + painter.rect_stroke(r, CornerRadius::ZERO, Stroke::new(2.0, crate::theme::canvas_accent(painter.ctx())), StrokeKind::Outside); } let dx = ((d.pos.x - d.from.x).abs() * 0.5).max(30.0) * if d.pos.x >= d.from.x { 1.0 } else { -1.0 }; let pts = [d.from, d.from + vec2(dx, 0.0), d.pos - vec2(dx, 0.0), d.pos]; - painter.add(egui::epaint::CubicBezierShape::from_points_stroke(pts, false, Color32::TRANSPARENT, Stroke::new(2.0, ACCENT))); - arrow(painter, &pts, ACCENT); + painter.add(egui::epaint::CubicBezierShape::from_points_stroke( + pts, + false, + Color32::TRANSPARENT, + Stroke::new(2.0, crate::theme::canvas_accent(painter.ctx())), + )); + arrow(painter, &pts, crate::theme::canvas_accent(painter.ctx())); plus_handle(painter, d.from, true); } None => { @@ -282,6 +295,26 @@ pub fn input(app: &mut FigApp, ui: &egui::Ui, resp: &egui::Response) -> bool { mod tests { use super::*; + /// A press on a connection handle takes the keyboard from the Pages list, as a press anywhere + /// else on the canvas does, so a later Delete can't take the page (#859). + #[test] + fn a_press_on_a_connection_handle_ends_the_pages_list_focus() { + let mut s = figcraft_engine::Session::new(); + let a = s.execute("frame.create", &json!({"x": 0, "y": 0, "width": 200, "height": 150})).unwrap(); + let a = crate::present_tests::id(&a); + s.execute("select.set", &json!({"ids": [a]})).unwrap(); + let mut u = crate::present_tests::Ui::new(s); + u.app.ui.right_tab = RightTab::Prototype; + u.run("view.zoomSelection", json!({})); + u.frames(2, Vec::new); + u.app.ui.pages_focus = u.app.session.ds().ok().map(|d| d.uid); + let doc = u.app.session.doc().unwrap().clone(); + let (_, at) = *handles(&u.app, &doc, &[a]).first().unwrap(); + u.button(at, true); + assert!(u.app.ui.pages_focus.is_none()); + u.button(at, false); + } + #[test] fn curves_leave_the_facing_side() { let a = Rect::from_min_size(pos2(0.0, 0.0), vec2(50.0, 20.0)); diff --git a/crates/ui-egui/src/proto_editor_tests.rs b/crates/ui-egui/src/proto_editor_tests.rs index c63e62c6..80d49616 100644 --- a/crates/ui-egui/src/proto_editor_tests.rs +++ b/crates/ui-egui/src/proto_editor_tests.rs @@ -304,3 +304,26 @@ fn escape_closes_prototype_editor_without_deselecting_layer() { u.key(pos2(100.0, 100.0), egui::Key::Escape); assert!(u.app.session.selection().is_empty(), "selection cleared on third escape"); } + +/// After a click in the Pages list, Delete that removes an interaction ends the list's hold on the +/// keyboard: with the layer deselected afterwards, Delete doesn't take the page (#859). +#[test] +fn deleting_an_interaction_ends_the_pages_list_focus() { + let (s, _a, _b, btn) = setup(); + let mut u = Ui::new(s); + let first = u.app.session.page().unwrap(); + u.run("page.add", json!({})); + u.run("page.select", json!({"id": first})); + // As after a click on the page's row (layers_keys_tests clicks the real row). + u.app.ui.pages_focus = u.app.session.ds().ok().map(|d| d.uid); + u.app.ui.right_tab = crate::RightTab::Prototype; + u.run("select.set", json!({"ids": [btn]})); + crate::proto_panel::open_editor(&u.ctx, Some((btn, 0))); + u.frames(1, Vec::new); + u.key(pos2(100.0, 100.0), egui::Key::Delete); + assert_eq!(u.app.session.doc().unwrap().node(btn).unwrap().interactions.len(), 0); + assert!(u.app.ui.pages_focus.is_none()); + u.run("select.none", json!({})); + u.key(pos2(100.0, 100.0), egui::Key::Delete); + assert_eq!(u.app.session.doc().unwrap().pages.len(), 2, "the page stays"); +} diff --git a/crates/ui-egui/src/proto_panel.rs b/crates/ui-egui/src/proto_panel.rs index 85af66c5..747dbaf6 100644 --- a/crates/ui-egui/src/proto_panel.rs +++ b/crates/ui-egui/src/proto_panel.rs @@ -53,6 +53,8 @@ pub fn delete_selected_interaction(app: &mut FigApp, ctx: &egui::Context) -> boo return false; } open_editor(ctx, None); + // Delete went to the interaction, so a later one doesn't go to the Pages list. + app.ui.pages_focus = None; let ids = targets_for(app, node); app.run("proto.interaction.remove", json!({"ids": ids, "index": index})); true @@ -763,7 +765,7 @@ fn trigger_rows(app: &mut FigApp, ui: &mut Ui, node: NodeId, k: usize, it: &Inte let id = capture_id().with("field"); ui.painter().rect_filled(r, CornerRadius::same(5), t.field); if cap { - ui.painter().rect_stroke(r, CornerRadius::same(5), Stroke::new(1.0, t.accent), StrokeKind::Inside); + ui.painter().rect_stroke(r, CornerRadius::same(5), Stroke::new(1.0, ACCENT), StrokeKind::Inside); ui.memory_mut(|m| m.request_focus(id)); let _ = ui.interact(r, id, Sense::focusable_noninteractive()); let pressed = ui.input(|i| { @@ -924,7 +926,20 @@ pub(crate) fn action_fields(app: &mut FigApp, ui: &mut Ui, doc: &Document, page: let w = ((ui.available_width() - 6.0) / 2.0).max(36.0); for (i, (name, key)) in [("X", "proto_ov_x"), ("Y", "proto_ov_y")].into_iter().enumerate() { let shown = offset.and_then(|o| o.get(i).copied()); - let Some(e) = widgets::number_field_bindable(ui, slot.id(key), Lead::Text(name), shown, "", w, 1.0, None, tr("Auto")).0 + // "Auto" is no offset (right below the layer), not 0: the word isn't taken + // as a value here. + let Some(e) = widgets::number_field_bindable( + ui, + slot.id(key), + Lead::Text(name), + shown, + "", + w, + 1.0, + None, + widgets::Empty::Text(tr("Auto")), + ) + .0 else { continue; }; @@ -1048,7 +1063,7 @@ fn animation_rows(app: &mut FigApp, ui: &mut Ui, node: NodeId, k: usize, it: &In let w = ui.available_width().max(200.0); let (r, rr) = ui.allocate_exact_size(vec2(w, 26.0), Sense::click()); if rr.hovered() { - ui.painter().rect_filled(r, CornerRadius::same(5), ACCENT); + ui.painter().rect_filled(r, CornerRadius::same(5), t.menu_hover); } let col = if rr.hovered() { Color32::WHITE } else { t.text }; curve(ui.painter(), Rect::from_min_size(pos2(r.min.x + 6.0, r.min.y + 4.0), vec2(28.0, 18.0)), *e, col, None); diff --git a/crates/ui-egui/src/right.rs b/crates/ui-egui/src/right.rs index 53eaf953..6738f300 100644 --- a/crates/ui-egui/src/right.rs +++ b/crates/ui-egui/src/right.rs @@ -11,7 +11,7 @@ use crate::i18n::{tr, trf, trn}; use crate::icons::{self, Icon}; use crate::list_drag::{ListDrag, move_flags, remove_flags}; use crate::style_picker::{self, StyleSlot}; -use crate::theme::{ACCENT, Tokens, regular, semibold}; +use crate::theme::{Tokens, regular, semibold}; use crate::widgets::{self, Edit, FIELD_H, FieldEdit, Lead, PAD}; use crate::{FigApp, RightTab}; @@ -84,12 +84,12 @@ pub fn right_panel(app: &mut FigApp, ui: &mut Ui) { // Top bar: people (you and the others in a live session), present, share. let (top, _) = ui.allocate_exact_size(vec2(w, 48.0), Sense::hover()); // As wide as its label needs (longer in some languages). - let label = ui.painter().layout_no_wrap(tr("Share").to_string(), semibold(11.5), Color32::WHITE); + let label = ui.painter().layout_no_wrap(tr("Share").to_string(), semibold(11.5), t.accent_on); let sw = (label.size().x + 24.0).max(56.0); let share = Rect::from_min_size(pos2(top.max.x - 14.0 - sw, top.center().y - 16.0), vec2(sw, 32.0)); let sresp = ui.interact(share, ui.id().with("share"), Sense::click()).tip(tr("Share: live session, export")); - ui.painter().rect_filled(share, CornerRadius::same(6), if sresp.hovered() { t.accent_hover } else { ACCENT }); - ui.painter().galley(share.center() - label.size() / 2.0, label, Color32::WHITE); + ui.painter().rect_filled(share, CornerRadius::same(6), if sresp.hovered() { t.accent_hover } else { t.accent }); + ui.painter().galley(share.center() - label.size() / 2.0, label, t.accent_on); crate::live::share_popover(app, &sresp); // Play presents; its chevron opens the play menu (present, or preview in a pane). let play = Rect::from_min_size(pos2(share.min.x - 50.0, top.center().y - 14.0), vec2(42.0, 28.0)); @@ -284,13 +284,15 @@ pub fn menu_row(ui: &mut Ui, label: &str, shortcut: &str, art_w: f32, art: impl a11y::own_focus(&resp); let hovered = resp.hovered() || resp.has_focus(); if hovered { - ui.painter().rect_filled(r, CornerRadius::same(5), ACCENT); + ui.painter().rect_filled(r, CornerRadius::same(5), t.menu_hover); } - let col = if hovered { Color32::WHITE } else { t.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 { Color32::WHITE } else { t.text_secondary }); + 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| { @@ -961,8 +951,8 @@ fn variable_modes(app: &mut FigApp, ui: &mut Ui, doc: &Document, sel: &[NodeId]) // The collection's name, then a wider mode picker ("Auto (Light)") across both field columns. let col = widgets::col_width(ui.available_width()); let (name_w, pick_w) = ((col * 0.7).round(), (col * 1.3).round()); - // Auto resolves from the parent of the first selected layer. - let parent_modes = sel.first().and_then(|i| doc.node(*i)).and_then(|n| n.parent).map(|p| doc.modes_for(p)).unwrap_or_default(); + // Auto resolves as for the first selected layer: from its parent, or an instance's main. + let parent_modes = sel.first().map(|i| doc.inherited_modes(*i)).unwrap_or_default(); let mut group = None; for r in &rows { let c = r.coll; @@ -2056,8 +2046,13 @@ fn grid_settings(app: &mut FigApp, ui: &mut Ui, ids: &[NodeId], list: &[LayoutGr // 0 is Auto for fixed-size columns (rows); a stretched grid has at least one. let count = (!auto_count(g)).then(|| f64::from(g.count.max(1))); let key = format!("layoutGrids/{i}/count"); - let e = - crate::variable_ui::number_field_or(app, ui, ids, &key, Id::new(("grid_field", "count", i)), count, col, tr("Auto"), Lead::None, ""); + let id = Id::new(("grid_field", "count", i)); + // A stretched grid has at least one band, so 0 (Auto) isn't a count it can take. + let e = if g.align == GridAlign::Stretch { + crate::variable_ui::number_field_or(app, ui, ids, &key, id, count, col, "", Lead::None, "") + } else { + crate::variable_ui::number_field_auto(app, ui, ids, &key, id, count, col, Lead::None, "") + }; app.field_run(e, "node.set", |v| { set(&|x| x.count = if x.align == GridAlign::Stretch { v.round().clamp(1.0, 1000.0) } else { v.round().clamp(0.0, 1000.0) } as u32) }); @@ -2090,11 +2085,17 @@ fn grid_settings(app: &mut FigApp, ui: &mut Ui, ids: &[NodeId], list: &[LayoutGr let span = if cols { tr("Width") } else { tr("Height") }; match g.align { GridAlign::Stretch => { - widgets::sublabels(ui, tr("Margin"), Some(tr("Gutter"))); + widgets::sublabels(ui, span, Some(tr("Margin"))); row(ui, 32.0, |ui| { + // The frame, divided by the count: shown as Auto and not editable. + ui.add_enabled_ui(false, |ui| { + let auto = widgets::Empty::Text(tr("Auto")); + widgets::number_field_bindable(ui, Id::new(("grid_field", "size", i)), Lead::Named(span), None, "", col, 1.0, None, auto) + }); field(app, ui, "offset", g.offset, &offset); - field(app, ui, "gutter", g.gutter, &gutter); }); + widgets::sublabels(ui, tr("Gutter"), None); + row(ui, 32.0, |ui| field(app, ui, "gutter", g.gutter, &gutter)); } GridAlign::Center => { widgets::sublabels(ui, span, Some(tr("Gutter"))); diff --git a/crates/ui-egui/src/selection_spacing.rs b/crates/ui-egui/src/selection_spacing.rs index 5110be1c..3a39955a 100644 --- a/crates/ui-egui/src/selection_spacing.rs +++ b/crates/ui-egui/src/selection_spacing.rs @@ -3,6 +3,7 @@ use std::sync::Arc; use egui::{CursorIcon, Id, Pos2, Rect, Stroke, Ui, vec2}; use figcraft_doc::{Document, NodeId}; +use figcraft_engine::selection_reorder::ReorderLayout; use figcraft_engine::selection_spacing::{Axis, SelectionSpacing, selection_spacing}; use serde_json::json; @@ -20,6 +21,7 @@ struct LayoutCache { ids: Vec, frame: u64, layout: Arc, + reorder: Option>, } fn layout(app: &FigApp, ctx: &egui::Context, ids: &[NodeId]) -> Arc { @@ -41,25 +43,68 @@ fn layout(app: &FigApp, ctx: &egui::Context, ids: &[NodeId]) -> Arc Option> { + layout(app, ctx, ids); + ctx.data(|d| d.get_temp::(Id::new("selection_spacing_layout"))).and_then(|c| c.reorder) +} + fn drag_id() -> Id { Id::new("selection_spacing_drag") } +/// The reorder ring of a layer with world bounds `b`: its centre on screen and hit radius. The +/// radius is 8 px, at most a quarter of the layer's smaller side on screen, so most of a small +/// layer still drags the selection; a layer under 16 px on screen has no ring. +fn ring(app: &FigApp, b: figcraft_geom::Rect) -> Option<(Pos2, f32)> { + let zoom = app.session.ds().map_or(1.0, |d| d.view.zoom); + let side = ((b.x1 - b.x0).min(b.y1 - b.y0) * zoom) as f32; + (side >= 16.0).then(|| (app.to_screen((b.x0 + b.x1) * 0.5, (b.y0 + b.y1) * 0.5), (side / 4.0).min(8.0))) +} + +#[derive(Clone)] +enum Action { + Spacing { axis: Axis, value: f64, last_value: Option }, + Reorder { layout: Arc, source: NodeId, target: NodeId, swap: bool }, +} + #[derive(Clone)] struct Drag { ids: Vec, - axis: Axis, + action: Action, start: Pos2, - value: f64, uid: u64, page: NodeId, moved: bool, - last_value: Option, +} + +fn start_drag(app: &mut FigApp, ctx: &egui::Context, ids: Vec, p: Pos2, action: Action) { + let label = match action { + Action::Spacing { .. } => tr("Spacing"), + Action::Reorder { .. } => tr("Reorder selection"), + }; + if app.session.begin_interaction(label).is_ok() + && let Ok(ds) = app.session.ds() + { + let drag = Drag { ids, action, start: p, uid: ds.uid, page: ds.page, moved: false }; + app.ui.layers_focus = false; + app.ui.pages_focus = None; + app.ui.key_box = None; + ctx.memory_mut(|m| { + if let Some(f) = m.focused() { + m.surrender_focus(f); + } + }); + ctx.data_mut(|d| d.insert_temp(drag_id(), drag)); + } } pub fn panel(app: &mut FigApp, ui: &mut Ui, ids: &[NodeId]) { @@ -146,7 +191,7 @@ pub fn cancel(app: &mut FigApp, ctx: &egui::Context) -> bool { /// Retire handles when another document, page, tool or editing context takes over. pub fn sync(app: &mut FigApp, ctx: &egui::Context) { if let Some(drag) = ctx.data(|d| d.get_temp::(drag_id())) { - let same = app.session.ds().is_ok_and(|d| d.uid == drag.uid && d.page == drag.page && d.selection == drag.ids); + let same = app.session.ds().is_ok_and(|d| d.uid == drag.uid && d.page == drag.page && d.selection == drag.ids && d.interaction.is_some()); if !same || !available(app) { cancel(app, ctx); } @@ -157,39 +202,77 @@ pub fn sync(app: &mut FigApp, ctx: &egui::Context) { pub fn input(app: &mut FigApp, ctx: &egui::Context, pos: Option, hovered: bool, space: bool) -> bool { let dragging = ctx.data(|d| d.get_temp::(drag_id())); if let Some(mut drag) = dragging { - let same = app.session.ds().is_ok_and(|d| d.uid == drag.uid && d.page == drag.page && d.selection == drag.ids); + let same = app.session.ds().is_ok_and(|d| d.uid == drag.uid && d.page == drag.page && d.selection == drag.ids && d.interaction.is_some()); if !same { cancel(app, ctx); return true; } let down = ctx.input(|i| i.pointer.primary_down()); if let Some(p) = pos { - let zoom = app.session.ds().map_or(1.0, |d| d.view.zoom).max(1e-6); - let distance = if drag.axis.vertical { p.y - drag.start.y } else { p.x - drag.start.x }; - drag.moved |= distance.abs() >= 3.0; - if drag.moved { - let value = ctx - .input(|i| { - let quantum = if i.modifiers.alt { - 0.1 - } else if i.modifiers.shift { - 10.0 - } else { - 1.0 - }; - ((drag.value + f64::from(distance) / zoom) / quantum).round() * quantum - }) - .clamp(-1e6, 1e6); - let key = if drag.axis.vertical { "vertical" } else { "horizontal" }; - if drag.last_value != Some(value) { - match app.session.execute("arrange.spacing", &json!({"ids":drag.ids,key:value,"fromGesture":true})) { - Ok(_) => drag.last_value = Some(value), - Err(e) => app.toast(e.to_string()), + match &mut drag.action { + Action::Spacing { axis, value: original, last_value } => { + let zoom = app.session.ds().map_or(1.0, |d| d.view.zoom).max(1e-6); + let distance = if axis.vertical { p.y - drag.start.y } else { p.x - drag.start.x }; + drag.moved |= distance.abs() >= 3.0; + if drag.moved { + let value = ctx + .input(|i| { + let quantum = if i.modifiers.alt { + 0.1 + } else if i.modifiers.shift { + 10.0 + } else { + 1.0 + }; + ((*original + f64::from(distance) / zoom) / quantum).round() * quantum + }) + .clamp(-1e6, 1e6); + let key = if axis.vertical { "vertical" } else { "horizontal" }; + if *last_value != Some(value) { + match app.session.execute("arrange.spacing", &json!({"ids":drag.ids,key:value,"fromGesture":true})) { + Ok(_) => *last_value = Some(value), + Err(e) => app.toast(e.to_string()), + } + } } } + Action::Reorder { layout, source, target, swap } if app.canvas.rect.contains(p) => { + drag.moved |= p.distance(drag.start) >= 3.0; + // Hit original slots, never the moving preview: otherwise a stationary + // pointer could alternate between two permutations on successive frames. + let nearest = layout + .slots + .iter() + .min_by(|a, b| { + let center = |b: figcraft_geom::Rect| app.to_screen((b.x0 + b.x1) * 0.5, (b.y0 + b.y1) * 0.5); + center(a.1).distance_sq(p).total_cmp(¢er(b.1).distance_sq(p)) + }) + .map(|(id, _)| *id) + .unwrap_or(*source); + let swapping = ctx.input(|i| i.modifiers.command); + if drag.moved && (nearest != *target || swapping != *swap) { + match app + .session + .execute("arrange.reorder", &json!({"ids":drag.ids,"source":source,"target":nearest,"swap":swapping,"fromGesture":true})) + { + Ok(_) => { + *target = nearest; + *swap = swapping; + } + Err(e) => app.toast(e.to_string()), + } + } + } + // A release over a panel or outside the window commits the last canvas + // preview; the release's sentinel coordinates must not select a new slot. + Action::Reorder { .. } => {} } } - ctx.set_cursor_icon(if drag.axis.vertical { CursorIcon::ResizeVertical } else { CursorIcon::ResizeHorizontal }); + ctx.set_cursor_icon(match &drag.action { + Action::Spacing { axis, .. } if axis.vertical => CursorIcon::ResizeVertical, + Action::Spacing { .. } => CursorIcon::ResizeHorizontal, + Action::Reorder { .. } => CursorIcon::Grabbing, + }); if !down { let _ = app.session.commit_interaction(); ctx.data_mut(|d| d.remove::(drag_id())); @@ -198,11 +281,26 @@ pub fn input(app: &mut FigApp, ctx: &egui::Context, pos: Option, hovered: } return true; } - if !hovered || space || !available(app) || ctx.input(|i| i.modifiers.command) { + if !hovered || space || !available(app) { return false; } let Some(p) = pos else { return false }; let ids = app.session.selection(); + if let Some(reorder) = reorder_layout(app, ctx, &ids) { + for (id, b) in &reorder.slots { + if ring(app, *b).is_some_and(|(center, r)| center.distance(p) <= r) { + ctx.set_cursor_icon(CursorIcon::Grab); + if ctx.input(|i| i.pointer.primary_pressed()) { + let source = *id; + start_drag(app, ctx, ids, p, Action::Reorder { layout: reorder, source, target: source, swap: false }); + } + return true; + } + } + } + if ctx.input(|i| i.modifiers.command) { + return false; + } let layout = layout(app, ctx, &ids); for axis in [layout.horizontal.as_ref(), layout.vertical.as_ref()].into_iter().flatten() { for h in handles(app, axis) { @@ -210,20 +308,8 @@ pub fn input(app: &mut FigApp, ctx: &egui::Context, pos: Option, hovered: continue; } ctx.set_cursor_icon(if axis.vertical { CursorIcon::ResizeVertical } else { CursorIcon::ResizeHorizontal }); - if ctx.input(|i| i.pointer.primary_pressed()) - && app.session.begin_interaction(tr("Spacing")).is_ok() - && let Ok(ds) = app.session.ds() - { - let drag = - Drag { ids, axis: axis.clone(), start: p, value: h.value, uid: ds.uid, page: ds.page, moved: false, last_value: axis.value }; - app.ui.layers_focus = false; - app.ui.key_box = None; - ctx.memory_mut(|m| { - if let Some(f) = m.focused() { - m.surrender_focus(f); - } - }); - ctx.data_mut(|d| d.insert_temp(drag_id(), drag)); + if ctx.input(|i| i.pointer.primary_pressed()) { + start_drag(app, ctx, ids, p, Action::Spacing { axis: axis.clone(), value: h.value, last_value: axis.value }); } return true; } @@ -244,8 +330,32 @@ pub fn draw(app: &FigApp, painter: &egui::Painter) { if drag.is_none() && !hover.is_some_and(|p| rect.contains(p)) { return; } - let axes = if let Some(d) = &drag { - vec![d.axis.clone()] + let reorder = match drag.as_ref().map(|d| &d.action) { + Some(Action::Reorder { layout, .. }) => Some(layout.clone()), + Some(Action::Spacing { .. }) => None, + None => reorder_layout(app, ctx, &ds.selection), + }; + if let Some(layout) = reorder { + for (id, _) in &layout.slots { + let Some((center, r)) = ds.doc.world_bounds(*id).and_then(|b| ring(app, b)) else { continue }; + let hot = hover.is_some_and(|p| center.distance(p) <= r); + let active = matches!(drag.as_ref().map(|d| &d.action), Some(Action::Reorder { source, .. }) if source == id); + if active { + painter.circle_filled(center, 4.5, PINK); + } else { + painter.circle_stroke(center, 4.5, Stroke::new(if hot { 2.5 } else { 1.5 }, PINK)); + } + } + } + if let Some(Drag { action: Action::Reorder { layout, target, .. }, moved: true, .. }) = &drag { + if let Some((_, b)) = layout.slots.iter().find(|(id, _)| id == target) { + let rect = Rect::from_two_pos(app.to_screen(b.x0, b.y0), app.to_screen(b.x1, b.y1)).expand(3.0); + painter.rect_stroke(rect, 0, Stroke::new(2.0, crate::theme::ACCENT), egui::StrokeKind::Outside); + } + return; + } + let axes = if let Some(Drag { action: Action::Spacing { axis, .. }, .. }) = &drag { + vec![axis.clone()] } else { let layout = layout(app, ctx, &ds.selection); [layout.horizontal.clone(), layout.vertical.clone()].into_iter().flatten().collect() diff --git a/crates/ui-egui/src/selection_spacing_tests.rs b/crates/ui-egui/src/selection_spacing_tests.rs index 453bd60d..2ee44742 100644 --- a/crates/ui-egui/src/selection_spacing_tests.rs +++ b/crates/ui-egui/src/selection_spacing_tests.rs @@ -205,3 +205,157 @@ fn stationary_gap_drag_does_not_change_the_document_again() { u.button(p + vec2(20.2 * z, 0.), false); assert_eq!(xy(&u, ids[1]), (200., 100.)); } + +#[test] +fn reorder_ring_drag_inserts_across_grid_rows_as_one_undo_step() { + let (mut u, ids) = scene(true); + let before = u.app.session.undo_depth().0; + let p = u.app.to_screen(125., 130.); + let target = u.app.to_screen(205., 230.); + let output = u.frame_output(vec![Event::PointerMoved(p)]); + assert_eq!(output.platform_output.cursor_icon, egui::CursorIcon::Grab); + drag(&mut u, p, target - p, true); + assert_eq!(xy(&u, ids[0]), (180., 200.)); + assert_eq!(xy(&u, ids[3]), (260., 100.)); + assert_eq!(u.app.session.selection(), ids); + assert_eq!(u.app.session.undo_depth().0, before + 1); + u.app.run("edit.undo", json!({})); + assert_eq!(xy(&u, ids[0]), (100., 100.)); + u.app.run("edit.redo", json!({})); + assert_eq!(xy(&u, ids[0]), (180., 200.)); +} + +#[test] +fn reorder_ring_escape_click_and_returning_home_make_no_undo_step() { + for action in [0, 1, 2] { + let (mut u, ids) = scene(false); + let history = u.app.session.undo_depth().0; + let before = u.app.session.doc().unwrap().clone(); + let p = u.app.to_screen(125., 130.); + let target = u.app.to_screen(285., 130.); + u.frame(vec![Event::PointerMoved(p)]); + if action == 0 { + u.button(p, true); + } else { + drag(&mut u, p, target - p, false); + assert_eq!(xy(&u, ids[0]), (260., 100.)); + if action == 1 { + key(&mut u, Key::Escape); + } else { + u.frame(vec![Event::PointerMoved(p)]); + } + } + u.button(p, false); + u.frame(vec![]); + assert_eq!(u.app.session.doc().unwrap(), &before); + assert_eq!(u.app.session.undo_depth().0, history); + assert_eq!(u.app.session.selection(), ids); + assert!(u.app.session.ds().unwrap().interaction.is_none()); + } +} + +#[test] +fn command_drag_swaps_and_a_stationary_pointer_keeps_the_preview() { + let (mut u, ids) = scene(true); + let p = u.app.to_screen(125., 130.); + let target = u.app.to_screen(205., 230.); + // The modifier can already be down when the handle is pressed. + let m = Modifiers::COMMAND; + u.frame_with(vec![Event::PointerMoved(p)], m); + u.frame_with(vec![Event::PointerButton { pos: p, button: egui::PointerButton::Primary, pressed: true, modifiers: m }], m); + u.frame_with(vec![Event::PointerMoved(target)], m); + assert_eq!(xy(&u, ids[0]), (180., 200.)); + assert_eq!(xy(&u, ids[4]), (100., 100.)); + assert_eq!(xy(&u, ids[1]), (180., 100.)); + let snapshot = u.app.session.ds().unwrap().doc.clone(); + for _ in 0..5 { + u.frame_with(vec![], m); + } + assert!(std::sync::Arc::ptr_eq(&snapshot, &u.app.session.ds().unwrap().doc)); + // Changing modifiers without moving the pointer switches to insertion. + u.frame(vec![]); + assert_eq!(xy(&u, ids[1]), (100., 100.)); + assert_eq!(xy(&u, ids[4]), (100., 200.)); + u.button(target, false); +} + +#[test] +fn switching_documents_during_reordering_rolls_back_its_owner() { + let (mut u, ids) = scene(false); + let before = u.app.session.doc().unwrap().clone(); + let p = u.app.to_screen(125., 130.); + let target = u.app.to_screen(285., 130.); + drag(&mut u, p, target - p, false); + u.app.run("file.new", json!({})); + u.app.session.begin_interaction("Unrelated").unwrap(); + u.frame(vec![]); + assert!(u.app.session.ds().unwrap().interaction.is_some()); + u.app.session.active = 0; + assert_eq!(u.app.session.doc().unwrap(), &before); + assert_eq!(u.app.session.selection(), ids); +} + +#[test] +fn reorder_release_outside_window_commits_and_undo_while_dragging_cancels() { + for undo in [false, true] { + let (mut u, ids) = scene(false); + let history = u.app.session.undo_depth().0; + let p = u.app.to_screen(125., 130.); + let target = u.app.to_screen(285., 130.); + drag(&mut u, p, target - p, false); + if undo { + let m = Modifiers::COMMAND; + u.frame_with(vec![Event::Key { key: Key::Z, physical_key: None, pressed: true, repeat: false, modifiers: m }], m); + } + u.frame(vec![Event::PointerGone]); + u.button(Pos2::new(-1., -1.), false); + u.frame(vec![]); + assert_eq!(u.app.session.undo_depth().0, history + usize::from(!undo)); + assert_eq!(xy(&u, ids[0]), if undo { (100., 100.) } else { (260., 100.) }); + assert!(u.app.session.ds().unwrap().interaction.is_none()); + } +} + +#[test] +fn an_externally_finished_reorder_cannot_resume_on_later_pointer_motion() { + let (mut u, ids) = scene(false); + let p = u.app.to_screen(125., 130.); + let target = u.app.to_screen(285., 130.); + drag(&mut u, p, target - p, false); + u.app.session.commit_interaction().unwrap(); + let history = u.app.session.undo_depth().0; + let committed = u.app.session.doc().unwrap().clone(); + let middle = u.app.to_screen(205., 130.); + u.frame(vec![Event::PointerMoved(middle)]); + u.button(middle, false); + u.frame(vec![]); + assert_eq!(u.app.session.doc().unwrap(), &committed); + assert_eq!(u.app.session.undo_depth().0, history); + assert_eq!(xy(&u, ids[0]), (260., 100.)); +} + +#[test] +fn small_layers_on_screen_have_no_reorder_ring_and_drag_the_selection_from_their_middle() { + let mut u = Ui::new(Session::new()); + let z = u.app.session.ds().unwrap().view.zoom; + // 12 px squares 36 px apart on screen: a regular row, too small for rings. + let ids: Vec = (0..3) + .map(|col| { + let p = json!({"x": (100. + f64::from(col) * 36.) / z, "y": 100. / z, "width": 12. / z, "height": 12. / z}); + NodeId(u.app.session.execute("shape.rectangle", &p).unwrap()["id"].as_u64().unwrap()) + }) + .collect(); + u.app.session.execute("select.set", &json!({"ids": ids})).unwrap(); + let before: Vec<_> = ids.iter().map(|id| xy(&u, *id)).collect(); + let p = u.app.to_screen(before[0].0 + 6. / z, before[0].1 + 6. / z); + let output = u.frame_output(vec![Event::PointerMoved(p)]); + assert_ne!(output.platform_output.cursor_icon, egui::CursorIcon::Grab); + drag(&mut u, p, vec2(0., 60.), true); + let after: Vec<_> = ids.iter().map(|id| xy(&u, *id)).collect(); + assert!(after[0].1 > before[0].1, "the selection moved: {before:?} -> {after:?}"); + assert!( + before.iter().zip(&after).all(|(b, a)| (a.0 - b.0).abs() < 1e-6 && (a.1 - b.1 - (after[0].1 - before[0].1)).abs() < 1e-6), + "all together, in order" + ); + assert_eq!(u.app.session.selection(), ids); +} diff --git a/crates/ui-egui/src/shortcuts.rs b/crates/ui-egui/src/shortcuts.rs index f3197960..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), } } @@ -279,7 +316,8 @@ fn key_event(app: &mut FigApp, key: Key, physical: Option, m: Modifiers, re if app.ui.layers_focus && !m.command && !m.alt && crate::layers::key_nav(app, key, m.shift) { return true; } - // Arrow nudges; with nothing selected the arrows pan the view. + // Arrow nudges, Alt held too (measuring stays on while the selection moves); with nothing + // selected the arrows pan the view. let (dx, dy) = match key { Key::ArrowLeft => (-1.0, 0.0), Key::ArrowRight => (1.0, 0.0), @@ -287,9 +325,11 @@ fn key_event(app: &mut FigApp, key: Key, physical: Option, m: Modifiers, re Key::ArrowDown => (0.0, 1.0), _ => (0.0, 0.0), }; - if (dx, dy) != (0.0, 0.0) && !m.alt { + 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})); @@ -386,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}"), @@ -412,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 966ea482..22713000 100644 --- a/crates/ui-egui/src/style_picker.rs +++ b/crates/ui-egui/src/style_picker.rs @@ -482,7 +482,7 @@ fn style_grid(ui: &mut Ui, list: &[(&Style, Key)], applied: Option<&Key>, editab None => preview(ui, r, s, StyleSlot::Fill), } if applied == Some(key) { - ui.painter().rect_stroke(r.expand(2.0), CornerRadius::same(5), Stroke::new(2.0, t.accent), egui::StrokeKind::Inside); + ui.painter().rect_stroke(r.expand(2.0), CornerRadius::same(5), Stroke::new(2.0, crate::theme::ACCENT), egui::StrokeKind::Inside); } else if resp.hovered() && !text { ui.painter().rect_stroke(r.expand(2.0), CornerRadius::same(4), Stroke::new(1.0, t.menu_text), egui::StrokeKind::Inside); } @@ -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 381b09dd..521fd05c 100644 --- a/crates/ui-egui/src/tabs.rs +++ b/crates/ui-egui/src/tabs.rs @@ -3,31 +3,104 @@ //! 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}; pub const HEIGHT: f32 = 40.0; +const MIN_TAB_WIDTH: f32 = 96.0; +const OVERFLOW_WIDTH: f32 = 32.0; +const OPEN_TIME: f64 = 0.18; + +/// Which tabs were open last frame and when the newly opened ones appeared. It is updated in place +/// in egui's memory; it only allocates when the set of tabs changes. +#[derive(Clone, Default)] +struct TabMotion { + initialized: bool, + known: Vec, + opening: Vec<(u64, f64)>, +} + +impl TabMotion { + /// Track tabs added since the previous frame and expire their animations. + fn begin_frame(&mut self, current: impl Iterator + Clone, now: f64, reduce_motion: bool) { + if !self.known.iter().copied().eq(current.clone()) { + if self.initialized && !reduce_motion { + for uid in current.clone().filter(|uid| !self.known.contains(uid)) { + self.opening.push((uid, now)); + } + } + self.known = current.collect(); + } + self.initialized = true; + if reduce_motion { + self.opening.clear(); + } else { + let known = &self.known; + self.opening.retain(|(uid, started)| known.contains(uid) && now - *started < OPEN_TIME); + } + } +} + +/// The entering tab's progress among `opening` (from [`TabMotion`]), or 1 when it is not animating. +fn open_progress(opening: &[(u64, f64)], uid: u64, now: f64) -> f32 { + opening.iter().find(|(u, _)| *u == uid).map_or(1.0, |(_, started)| ((now - started) / OPEN_TIME).clamp(0.0, 1.0) as f32) +} + +struct TabLayout { + first: usize, + widths: Vec, + overflow: bool, +} + +/// Shrink open tabs before moving the excess into the overflow menu. +fn tab_layout(preferred: &[f32], available: f32, active: Option) -> TabLayout { + let count = preferred.len(); + let overflow = count as f32 * MIN_TAB_WIDTH > available; + let visible = if overflow { ((available - OVERFLOW_WIDTH).max(0.0) / MIN_TAB_WIDTH).floor() as usize } else { count }.min(count); + let first = active.unwrap_or(0).saturating_sub(visible / 2).min(count - visible); + let mut widths = preferred[first..first + visible].to_vec(); + let budget = (available - if overflow { OVERFLOW_WIDTH } else { 0.0 }).max(0.0); + let mut excess = (widths.iter().sum::() - budget).max(0.0); + while excess > 0.01 { + let flexible = widths.iter().filter(|w| **w > MIN_TAB_WIDTH + 0.01).count(); + if flexible == 0 { + break; + } + let share = excess / flexible as f32; + let mut removed = 0.0; + for width in &mut widths { + let shrink = (*width - MIN_TAB_WIDTH).max(0.0).min(share); + *width -= shrink; + removed += shrink; + } + excess -= removed; + } + TabLayout { first, widths, overflow } +} + +/// Final document index and insertion marker for a tab dropped among the visible tabs. +fn tab_drop_position(visible: &[(usize, Rect)], from: usize, pointer_x: f32) -> Option<(usize, f32)> { + let others: Vec<_> = visible.iter().copied().filter(|(index, _)| *index != from).collect(); + let slot = others.partition_point(|(_, rect)| rect.center().x < pointer_x); + if let Some(&(index, rect)) = others.get(slot) { + Some((index - usize::from(from < index), rect.min.x)) + } else { + let &(index, rect) = others.last()?; + Some((index + usize::from(from > index), rect.max.x)) + } +} pub fn tab_bar(app: &mut FigApp, ui: &mut Ui) { let t = Tokens::get(ui.ctx()); egui::Panel::top("tab_bar").exact_size(HEIGHT).frame(egui::Frame::NONE.fill(t.tab_bar)).show(ui, |ui| { let rect = ui.max_rect(); - // Empty space moves the window (first, so the tabs and buttons on top take their clicks). - if app.ui.title_bar != TitleBar::Native { - let drag = ui.interact(rect, ui.id().with("titlebar_drag"), Sense::click_and_drag()); - if drag.drag_started() { - ui.ctx().send_viewport_cmd(egui::ViewportCommand::StartDrag); - } - if drag.double_clicked() { - let max = ui.input(|i| i.viewport().maximized.unwrap_or(false)); - ui.ctx().send_viewport_cmd(egui::ViewportCommand::Maximized(!max)); - } - } let buttons_w = if app.ui.title_bar == TitleBar::Custom { window_buttons(app, ui, rect) } else { 0.0 }; let mut x = rect.min.x + if app.ui.title_bar == TitleBar::Inset { 76.0 } else { 8.0 }; // Home. @@ -42,35 +115,80 @@ 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; ui.painter().line_segment([pos2(x, rect.min.y), pos2(x, rect.max.y)], Stroke::new(1.0, t.border)); + let document_tabs: Vec = app.session.docs.iter().enumerate().filter_map(|(i, d)| app.shows(d.uid).then_some(i)).collect(); + let now = ui.input(|input| input.time); + let motion_id = ui.id().with("tab_motion"); + let current_uids = document_tabs.iter().filter_map(|&i| app.session.docs.get(i).map(|d| d.uid)); + let reduce_motion = app.reduce_motion(); + // Cloning `opening` is free while nothing animates (an empty Vec). + let opening = ui.ctx().data_mut(|data| { + let motion = data.get_temp_mut_or_default::(motion_id); + motion.begin_frame(current_uids, now, reduce_motion); + motion.opening.clone() + }); + let preferred: Vec = document_tabs + .iter() + .map(|&i| { + let d = &app.session.docs[i]; + let mark_w = if live_mark(app, d.uid).is_some() { 18.0 } else { 0.0 }; + let dot_w = if d.is_dirty() && mark_w > 0.0 { 12.0 } else { 0.0 }; + (regular(12.0).size * 0.6 * d.doc.name.chars().count() as f32 + 64.0 + mark_w + dot_w).clamp(120.0, 220.0) + }) + .collect(); + // Leave the new-tab button, theme toggle, window controls and a draggable gap free. + 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()); + let mut tab_x = x; + for &width in &layout.widths { + tab_rects.push(Rect::from_min_size(pos2(tab_x, rect.min.y), vec2(width, HEIGHT))); + tab_x += width; + } let mut switch = None; let mut close = None; // A tab dragged off the strip, or "Open in new window", moves to a window of its own. let mut detach = None; + let mut dragging = None; + let mut dropped = None; let several = !ui.ctx().embed_viewports(); - for (i, d) in app.session.docs.iter().enumerate().filter(|(_, d)| app.shows(d.uid)) { + for (slot, &w) in layout.widths.iter().enumerate() { + let i = document_tabs[layout.first + slot]; + let d = &app.session.docs[i]; let active = i == app.session.active && !app.ui.home && app.ui.present.is_none(); - let name = d.doc.name.clone(); + let name = &d.doc.name; // In a live session: a mark after the name says whether others get in. let live = live_mark(app, d.uid); let mark_w = if live.is_some() { 18.0 } else { 0.0 }; - // (A file in a session is rarely saved here: its unsaved dot gets room beside the mark.) let dirty = d.is_dirty(); - let dot_w = if dirty && live.is_some() { 12.0 } else { 0.0 }; - let w = (regular(12.0).size * 0.6 * name.chars().count() as f32 + 64.0 + mark_w + dot_w).clamp(120.0, 220.0); - let r = Rect::from_min_size(pos2(x, rect.min.y), vec2(w, HEIGHT)); - let resp = ui.interact(r, ui.id().with(("tab", d.uid)), if several { Sense::click_and_drag() } else { Sense::click() }); - if resp.drag_stopped() + let r = tab_rects[slot]; + 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(("tab", d.uid)), Sense::click_and_drag()); + let cresp = ui.interact(close_r, ui.id().with(("tabclose", d.uid)), Sense::click()).tip(tr("Close tab")); + if (resp.dragged() || resp.drag_stopped()) && let Some(p) = ui.input(|inp| inp.pointer.latest_pos()) - && (p.y - rect.center().y).abs() > HEIGHT * 2.0 { - detach = Some((i, Some(p))); + if resp.drag_stopped() { + if several && (p.y - rect.center().y).abs() > HEIGHT * 2.0 { + detach = Some((i, Some(p))); + } else if (rect.min.y - 12.0..=rect.max.y + 12.0).contains(&p.y) { + dropped = Some((i, p.x)); + } + } else { + dragging = Some((i, p)); + } } if several { resp.context_menu(|ui| { @@ -82,49 +200,52 @@ pub fn tab_bar(app: &mut FigApp, ui: &mut Ui) { }); } let label = match live { - Some(true) => trf("{name}, in a live session", &[("name", &name)]), - Some(false) => trf("{name}, online, open only to you", &[("name", &name)]), + Some(true) => trf("{name}, in a live session", &[("name", name)]), + Some(false) => trf("{name}, online, open only to you", &[("name", name)]), None => name.clone(), }; let resp = if live.is_some() { resp.on_hover_text(&label) } else { resp }; crate::a11y::tab(&resp, &label, active); + let progress = open_progress(&opening, d.uid, now); + let paint_r = r.translate(vec2(-28.0 * (1.0 - progress).powi(2), 0.0)); + let painter = ui.painter().with_clip_rect(r); if active { - ui.painter().rect_filled(r, CornerRadius::ZERO, t.tab_active); - } else if resp.hovered() { - ui.painter().rect_filled(r, CornerRadius::ZERO, t.hover); + painter.rect_filled(paint_r, CornerRadius::ZERO, t.tab_active.linear_multiply(progress)); + } else if resp.hovered() || cresp.hovered() { + painter.rect_filled(paint_r, CornerRadius::ZERO, t.hover.linear_multiply(progress)); } icons::paint( - ui.painter(), - Rect::from_center_size(pos2(r.min.x + 18.0, r.center().y), vec2(14.0, 14.0)), - Icon::Logo, - crate::theme::ACCENT, + &painter, + Rect::from_center_size(pos2(paint_r.min.x + 18.0, paint_r.center().y), vec2(14.0, 14.0)), + 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. let shown = crate::bidi::paint_name( - ui.painter(), - pos2(r.min.x + 32.0, r.center().y), - &name, + &painter, + pos2(paint_r.min.x + 32.0, paint_r.center().y), + name, regular(12.0), - t.text, - w - mark_w - if dirty { 72.0 } else { 60.0 }, + t.text.linear_multiply(progress), + (w - mark_w - if dirty { 72.0 } else { 60.0 }).max(0.0), ); if let Some(shared) = live { - let at = Rect::from_center_size(pos2(r.max.x - 38.0, r.center().y), vec2(14.0, 14.0)); - icons::paint(ui.painter(), at, crate::online::mark(shared), if shared { t.success } else { t.icon_secondary }); + let at = Rect::from_center_size(pos2(paint_r.max.x - 38.0, paint_r.center().y), vec2(14.0, 14.0)); + icons::paint(&painter, at, crate::online::mark(shared), if shared { t.success } else { t.icon_secondary }.linear_multiply(progress)); } if dirty { - ui.painter().text(pos2(shown.max.x + 4.0, r.center().y), Align2::LEFT_CENTER, "•", regular(12.0), t.text); + painter.text(pos2(shown.max.x + 4.0, paint_r.center().y), Align2::LEFT_CENTER, "•", regular(12.0), t.text.linear_multiply(progress)); } - let cr = Rect::from_center_size(pos2(r.max.x - 16.0, r.center().y), vec2(18.0, 18.0)); - if resp.hovered() || active { - let cresp = ui.interact(cr, ui.id().with(("tabclose", d.uid)), Sense::click()).tip(tr("Close tab")); + let cr = Rect::from_center_size(close_r.center() + vec2(paint_r.min.x - r.min.x, 0.0), vec2(18.0, 18.0)); + // The close target never overlaps the draggable body, including on inactive tabs. + if resp.hovered() || cresp.hovered() || active { 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); - if cresp.clicked() { - close = Some(i); + painter.rect_filled(cr, CornerRadius::same(4), t.pressed.linear_multiply(progress)); } + icons::paint(&painter, cr.shrink(4.0), Icon::Close, t.icon_secondary.linear_multiply(progress)); + } + if cresp.clicked() { + close = Some(i); } if resp.clicked() && close.is_none() { switch = Some(i); @@ -135,41 +256,76 @@ pub fn tab_bar(app: &mut FigApp, ui: &mut Ui) { 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)); } - // Presentations live in their own tab. 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 resp = ui.interact(r, ui.id().with("presentation_tab"), Sense::click()).tip(tr("Present")); - 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() { + let visible: Vec<(usize, Rect)> = tab_rects.iter().copied().enumerate().map(|(slot, r)| (document_tabs[layout.first + slot], r)).collect(); + if let Some((from, p)) = dragging + && (rect.min.y - 12.0..=rect.max.y + 12.0).contains(&p.y) + && let Some((_, at)) = tab_drop_position(&visible, from, p.x) + { + ui.painter().line_segment([pos2(at, rect.min.y + 5.0), pos2(at, rect.max.y - 5.0)], Stroke::new(3.0, crate::theme::ACCENT)); + } + let reorder = dropped.and_then(|(from, at)| tab_drop_position(&visible, from, at).map(|(to, _)| (from, to))); + if 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("tab_overflow"), Sense::click()).tip(tr("Open files")); + crate::a11y::menu_button(&resp, tr("Open files"), 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(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(pos2(r.max.x - 16.0, r.center().y), vec2(18.0, 18.0)); - if resp.hovered() || active { - let close_resp = ui.interact(cr, ui.id().with("presentation_close"), Sense::click()).tip(tr("Close preview")); - 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); - } - } else if resp.clicked() && !active { - resume_presentation(app); + 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 &i in &document_tabs { + let Some(d) = app.session.docs.get(i) else { continue }; + let on = i == app.session.active && !app.ui.home && app.ui.present.is_none(); + let label = if d.is_dirty() { format!("{} •", d.doc.name) } else { d.doc.name.clone() }; + if crate::right::menu_check(ui, on, &label, "") { + switch = Some(i); + ui.close(); + } + } + }); + }); + x += OVERFLOW_WIDTH; + } + // 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. + 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); + } + 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")); @@ -178,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; } @@ -195,13 +349,28 @@ pub fn tab_bar(app: &mut FigApp, ui: &mut Ui) { let cmd = if app.ui.dark { "view.themeLight" } else { "view.themeDark" }; app.run(cmd, json!({})); } - if let Some(i) = switch { - if let Some(p) = app.ui.present.take() { - app.ui.paused_present = Some(p); + // Only the free space drags the window. A full-bar drag target steals tab drags. + if app.ui.title_bar != TitleBar::Native && x + 34.0 < theme_r.min.x { + let free = Rect::from_min_max(pos2(x + 34.0, rect.min.y), pos2(theme_r.min.x, rect.max.y)); + let drag = ui.interact(free, ui.id().with("titlebar_drag"), Sense::click_and_drag()); + if drag.drag_started() { + ui.ctx().send_viewport_cmd(egui::ViewportCommand::StartDrag); } + if drag.double_clicked() { + let max = ui.input(|i| i.viewport().maximized.unwrap_or(false)); + ui.ctx().send_viewport_cmd(egui::ViewportCommand::Maximized(!max)); + } + } + if let Some(i) = switch { + 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))); @@ -209,6 +378,11 @@ pub fn tab_bar(app: &mut FigApp, ui: &mut Ui) { app.toast(e); } } + if let Some((from, to)) = reorder + && from != to + { + app.run("file.moveTab", json!({"from": from, "to": to})); + } if let Some(i) = close { // A branch tab's edits live on in its main file: closing it loses nothing. let dirty = app.session.docs.get(i).is_some_and(|d| d.is_dirty() && d.branch_of.is_none()); @@ -224,27 +398,171 @@ 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; - } } - let _ = Color32::TRANSPARENT; - let _ = Align2::LEFT_TOP; + if !opening.is_empty() { + ui.ctx().request_repaint(); + } }); } -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 @@ -325,3 +643,66 @@ pub fn resize_edges(ctx: &egui::Context) { } }); } + +#[cfg(test)] +mod layout_tests { + 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]; + let layout = tab_layout(&preferred, 1100.0, Some(5)); + assert!(!layout.overflow); + assert_eq!(layout.first, 0); + assert_eq!(layout.widths.len(), preferred.len()); + assert!(layout.widths.iter().all(|&width| (width - 110.0).abs() < 0.01)); + } + + #[test] + fn excess_tabs_leave_room_for_overflow_and_keep_the_active_tab_visible() { + let preferred = vec![120.0; 24]; + let available = 1100.0; + let layout = tab_layout(&preferred, available, Some(23)); + assert!(layout.overflow); + assert!(layout.first <= 23 && 23 < layout.first + layout.widths.len()); + assert!(layout.widths.iter().all(|&width| width >= MIN_TAB_WIDTH)); + assert!(layout.widths.iter().sum::() + OVERFLOW_WIDTH <= available + 0.01); + } + + #[test] + fn drop_position_places_a_tab_between_its_neighbors() { + let visible = (0..4) + .map(|index| (index, Rect::from_min_max(pos2(index as f32 * 120.0, 0.0), pos2((index + 1) as f32 * 120.0, 40.0)))) + .collect::>(); + assert_eq!(tab_drop_position(&visible, 0, 330.0).map(|(to, _)| to), Some(2)); + assert_eq!(tab_drop_position(&visible, 3, 30.0).map(|(to, _)| to), Some(0)); + } + + #[test] + fn new_tabs_animate_unless_motion_is_reduced() { + let mut motion = TabMotion::default(); + motion.begin_frame([1].into_iter(), 0.0, false); + assert!(motion.opening.is_empty(), "the tabs open at start-up don't animate"); + motion.begin_frame([1, 2].into_iter(), 1.0, false); + assert_eq!(open_progress(&motion.opening, 2, 1.0), 0.0); + assert_eq!(open_progress(&motion.opening, 1, 1.0), 1.0); + motion.begin_frame([1, 2].into_iter(), 1.0 + 2.0 * OPEN_TIME, false); + assert!(motion.opening.is_empty(), "the animation ends"); + motion.begin_frame([1, 2, 3].into_iter(), 2.0, true); + assert!(motion.opening.is_empty(), "Reduce motion opens tabs at once"); + } +} diff --git a/crates/ui-egui/src/theme.rs b/crates/ui-egui/src/theme.rs index a4fa8754..611371ce 100644 --- a/crates/ui-egui/src/theme.rs +++ b/crates/ui-egui/src/theme.rs @@ -1,6 +1,6 @@ //! Design tokens (UI3 light and dark) and fonts. -use std::sync::Arc; +use std::sync::{Arc, OnceLock}; use egui::{Color32, Context, FontData, FontDefinitions, FontFamily, FontId, Stroke, TextStyle, Visuals}; @@ -14,6 +14,8 @@ pub struct Tokens { pub border_strong: Color32, pub text: Color32, pub text_secondary: Color32, + /// Dim text: disabled controls, placeholders, hints. At least 3:1 against the panels, fields + /// and selected rows, yet dimmer than `text_secondary`. pub text_tertiary: Color32, pub icon: Color32, pub icon_secondary: Color32, @@ -23,6 +25,7 @@ pub struct Tokens { pub field_hover_border: Color32, pub accent: Color32, pub accent_hover: Color32, + pub accent_on: Color32, pub accent_text: Color32, pub selected_row: Color32, pub selected_row_child: Color32, @@ -44,11 +47,42 @@ pub struct Tokens { pub focus: Color32, } -pub const ACCENT: Color32 = Color32::from_rgb(0x0D, 0x99, 0xFF); +/// FigCraft's brand green for solid surfaces. +pub const BRAND: Color32 = Color32::from_rgb(0x02, 0xDA, 0x96); +/// Contrast-safe green for thin selection outlines and white-on-green labels. +/// It reaches 4.5:1 on white and 3:1 on the dark canvas. +pub const ACCENT: Color32 = Color32::from_rgb(0x00, 0x87, 0x5F); +/// Ink on the bright green brand surface (white would not have enough contrast). +pub const ACCENT_ON: Color32 = Color32::from_rgb(0x07, 0x35, 0x2A); +/// Id of the canvas overlays' green for this frame ([`set_canvas_page`]). +const CANVAS_ACCENT: &str = "figcraft-canvas-accent"; + +/// Remember the page colour the canvas shows. Its overlays (outlines, handles, arrows) take +/// whichever green contrasts more with it: the bright one on a dark page, the darker on a light one. +pub fn set_canvas_page(ctx: &Context, page: Color32) { + let pick = if c(BRAND).contrast(c(page)) > c(ACCENT).contrast(c(page)) { BRAND } else { ACCENT }; + ctx.data_mut(|d| d.insert_temp(egui::Id::new(CANVAS_ACCENT), pick)); +} + +/// `x` (opaque) as a document colour, for its contrast with another. +fn c(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 green for overlays drawn on the page ([`set_canvas_page`]); [`ACCENT`] before a page shows. +pub fn canvas_accent(ctx: &Context) -> Color32 { + ctx.data(|d| d.get_temp(egui::Id::new(CANVAS_ACCENT))).unwrap_or(ACCENT) +} + +/// A fill under white text: the darker green stands in for the bright one. +pub fn under_white(c: Color32) -> Color32 { + if c == BRAND { ACCENT } else { c } +} + pub const COMPONENT: Color32 = Color32::from_rgb(0x97, 0x47, 0xFF); /// Handoff status badges (white text on these, in both themes). -pub const DEV_READY: Color32 = Color32::from_rgb(0x14, 0xAE, 0x5C); -pub const DEV_CHANGED: Color32 = Color32::from_rgb(0xD9, 0x7A, 0x06); +pub const DEV_READY: Color32 = Color32::from_rgb(0x08, 0x7D, 0x4F); +pub const DEV_CHANGED: Color32 = Color32::from_rgb(0xA7, 0x5B, 0x00); pub const DEV_COMPLETED: Color32 = Color32::from_rgb(0x6E, 0x6E, 0x73); impl Tokens { @@ -63,23 +97,24 @@ impl Tokens { text: Color32::from_rgb(0x1E, 0x1E, 0x1E), // 4.7:1 on fields and hovered rows (#F5F5F5), 4.6:1 on selected rows: WCAG AA. text_secondary: Color32::from_rgb(0x6E, 0x6E, 0x6E), - text_tertiary: Color32::from_rgb(0xB3, 0xB3, 0xB3), + text_tertiary: Color32::from_rgb(0x8A, 0x8A, 0x8A), icon: Color32::from_rgb(0x1E, 0x1E, 0x1E), icon_secondary: Color32::from_rgb(0x75, 0x75, 0x75), hover: Color32::from_rgb(0xF5, 0xF5, 0xF5), pressed: Color32::from_rgb(0xE6, 0xE6, 0xE6), field: Color32::from_rgb(0xF5, 0xF5, 0xF5), field_hover_border: Color32::from_rgb(0xE6, 0xE6, 0xE6), - accent: ACCENT, - accent_hover: Color32::from_rgb(0x00, 0x7B, 0xE5), - // 5.2:1 on white, 4.6:1 on selected rows: WCAG AA for text. - accent_text: Color32::from_rgb(0x00, 0x6B, 0xD1), - selected_row: Color32::from_rgb(0xE5, 0xF4, 0xFF), - selected_row_child: Color32::from_rgb(0xF2, 0xF9, 0xFF), + accent: BRAND, + accent_hover: Color32::from_rgb(0x00, 0xAD, 0x78), + accent_on: ACCENT_ON, + // Darker green keeps links and labels legible on white and selected rows. + accent_text: Color32::from_rgb(0x00, 0x7A, 0x56), + selected_row: Color32::from_rgb(0xE0, 0xFB, 0xF1), + selected_row_child: Color32::from_rgb(0xF1, 0xFC, 0xF7), component: COMPONENT, component_row: Color32::from_rgb(0xF1, 0xE9, 0xFF), danger: Color32::from_rgb(0xF2, 0x48, 0x22), - success: Color32::from_rgb(0x14, 0xAE, 0x5C), + success: DEV_READY, warning: Color32::from_rgb(0xFF, 0xCD, 0x29), guide: Color32::from_rgb(0xF2, 0x48, 0x22), tab_bar: Color32::from_rgb(0xE8, 0xE8, 0xE8), @@ -89,7 +124,7 @@ impl Tokens { menu_text: Color32::from_rgb(0xFF, 0xFF, 0xFF), menu_hover: ACCENT, menu_dim: Color32::from_rgb(0x9A, 0x9A, 0x9A), - focus: Color32::from_rgb(0x00, 0x6B, 0xD1), + focus: Color32::from_rgb(0x00, 0x7A, 0x56), } } @@ -103,22 +138,23 @@ impl Tokens { border_strong: Color32::from_rgb(0x55, 0x55, 0x55), text: Color32::from_rgb(0xFF, 0xFF, 0xFF), text_secondary: Color32::from_rgb(0xB3, 0xB3, 0xB3), - text_tertiary: Color32::from_rgb(0x75, 0x75, 0x75), + text_tertiary: Color32::from_rgb(0x88, 0x88, 0x88), icon: Color32::from_rgb(0xF5, 0xF5, 0xF5), icon_secondary: Color32::from_rgb(0xB3, 0xB3, 0xB3), hover: Color32::from_rgb(0x38, 0x38, 0x38), pressed: Color32::from_rgb(0x44, 0x44, 0x44), field: Color32::from_rgb(0x38, 0x38, 0x38), field_hover_border: Color32::from_rgb(0x4D, 0x4D, 0x4D), - accent: ACCENT, - accent_hover: Color32::from_rgb(0x3D, 0xAD, 0xFF), - accent_text: Color32::from_rgb(0x7C, 0xC4, 0xF8), - selected_row: Color32::from_rgb(0x2A, 0x45, 0x5C), - selected_row_child: Color32::from_rgb(0x2D, 0x37, 0x41), + accent: BRAND, + accent_hover: Color32::from_rgb(0x52, 0xE9, 0xB7), + accent_on: ACCENT_ON, + accent_text: Color32::from_rgb(0x52, 0xE9, 0xB7), + selected_row: Color32::from_rgb(0x16, 0x42, 0x36), + selected_row_child: Color32::from_rgb(0x12, 0x37, 0x2D), component: Color32::from_rgb(0xD2, 0x7C, 0xFF), component_row: Color32::from_rgb(0x3E, 0x30, 0x52), danger: Color32::from_rgb(0xF2, 0x48, 0x22), - success: Color32::from_rgb(0x14, 0xAE, 0x5C), + success: DEV_READY, warning: Color32::from_rgb(0xFF, 0xCD, 0x29), guide: Color32::from_rgb(0xF2, 0x48, 0x22), tab_bar: Color32::from_rgb(0x1E, 0x1E, 0x1E), @@ -128,7 +164,7 @@ impl Tokens { menu_text: Color32::from_rgb(0xFF, 0xFF, 0xFF), menu_hover: ACCENT, menu_dim: Color32::from_rgb(0x9A, 0x9A, 0x9A), - focus: ACCENT, + focus: BRAND, } } @@ -186,7 +222,9 @@ fn visuals(t: Tokens) -> Visuals { v.window_corner_radius = egui::CornerRadius::same(13); v.menu_corner_radius = egui::CornerRadius::same(13); v.selection.bg_fill = t.accent.gamma_multiply(0.3); - v.selection.stroke = Stroke::new(1.0, t.accent); + // Also the colour of selected text on that highlight (and a focused text box's outline): the + // theme's readable green, not the darker one that sinks into the dark theme's highlight. + v.selection.stroke = Stroke::new(1.0, t.accent_text); v.hyperlink_color = t.accent_text; for w in [&mut v.widgets.inactive, &mut v.widgets.hovered, &mut v.widgets.active, &mut v.widgets.open, &mut v.widgets.noninteractive] { w.corner_radius = egui::CornerRadius::same(5); @@ -202,10 +240,10 @@ fn visuals(t: Tokens) -> Visuals { v.widgets.hovered.bg_stroke = Stroke::new(1.0, t.field_hover_border); v.widgets.active.bg_fill = t.pressed; v.widgets.active.weak_bg_fill = t.pressed; - v.widgets.active.bg_stroke = Stroke::new(1.0, t.accent); + v.widgets.active.bg_stroke = Stroke::new(1.0, ACCENT); v.popup_shadow = egui::Shadow { offset: [0, 4], blur: 16, spread: 0, color: t.shadow }; v.window_shadow = egui::Shadow { offset: [0, 8], blur: 24, spread: 0, color: t.shadow }; - v.text_cursor.stroke = Stroke::new(1.0, t.accent); + v.text_cursor.stroke = Stroke::new(1.0, ACCENT); v } @@ -226,7 +264,8 @@ pub fn on_dark(ui: &mut egui::Ui, f: impl FnOnce(&mut egui::Ui) -> R) -> R { r } -/// UI fonts: Inter (from the craft-fonts build input) when present, else egui's defaults; with +/// UI fonts: Inter and the Archivo wordmark (from the craft-fonts build input) when present; +/// otherwise egui's defaults. With /// `system`, also system faces for scripts those lack (Hebrew, joined Arabic) so names in them show, /// and faces for the CJK scripts in `cjk` ([`crate::cjk`]). pub fn install_fonts(ctx: &Context, system: bool, cjk: u8) { @@ -252,8 +291,20 @@ pub fn install_fonts(ctx: &Context, system: bool, cjk: u8) { } semi.extend(fonts.families.get(&FontFamily::Proportional).cloned().unwrap_or_default()); fonts.families.insert(FontFamily::Name("semibold".into()), semi); + // Keep the logo's typeface independent of UI text. System Archivo is used when available; + // releases with craft-fonts embed it so the wordmark does not depend on installation. + let archivo = find("Archivo", "SemiBold").map(FontData::from_static).or_else(|| { + system.then(|| figcraft_text::find_face("Archivo", "SemiBold")).flatten().filter(|face| face.family == "Archivo").map(|face| font_data(&face)) + }); + let mut brand = Vec::new(); + if let Some(data) = archivo { + fonts.font_data.insert("archivo".into(), Arc::new(data)); + brand.push("archivo".into()); + } + brand.extend(fonts.families.get(&FontFamily::Name("semibold".into())).cloned().unwrap_or_default()); + fonts.families.insert(FontFamily::Name("brand".into()), brand); // CJK and other scripts from craft-fonts as fallbacks (Noto Sans CJK only with FIGCRAFT_EMBED_CJK). - const BUILT_IN: [&str; 4] = ["Inter", "BIZ UDPGothic", "Noto Sans CJK SC", "Noto Sans Arabic"]; + const BUILT_IN: [&str; 5] = ["Inter", "BIZ UDPGothic", "Noto Sans CJK SC", "Noto Sans Arabic", "Noto Sans Georgian"]; let lang = crate::i18n::current().map_or("en", |l| l.code); // A Chinese UI draws Han in a Chinese face: the built-in Japanese one goes after the system faces. let late = |family: &str| family == "BIZ UDPGothic" && crate::cjk::language(lang) == crate::cjk::HAN; @@ -339,6 +390,40 @@ pub fn semibold(size: f32) -> FontId { FontId::new(size, FontFamily::Name("semibold".into())) } +pub fn brand(size: f32) -> FontId { + FontId::new(size, FontFamily::Name("brand".into())) +} + +/// The two-tone Archivo wordmark used in the home sidebar and About window. +pub fn paint_wordmark(painter: &egui::Painter, left_center: egui::Pos2, size: f32, t: &Tokens) { + let font = brand(size); + let fig = painter.layout_no_wrap("Fig".into(), font.clone(), t.text); + let fig_width = fig.size().x; + painter.galley(egui::pos2(left_center.x, left_center.y - fig.size().y / 2.0), fig, t.text); + painter.text(egui::pos2(left_center.x + fig_width, left_center.y), egui::Align2::LEFT_CENTER, "Craft", font, t.accent_text); +} + +/// Paint the owner's original chameleon icon from the existing app asset. +pub fn paint_app_icon(ui: &egui::Ui, rect: egui::Rect) { + static ICON: OnceLock> = OnceLock::new(); + let image = ICON.get_or_init(|| { + let rgba = image::load_from_memory(include_bytes!("../../../assets/app-icon/figcraft-64.png")).ok()?.to_rgba8(); + Some(egui::ColorImage::from_rgba_unmultiplied([rgba.width() as usize, rgba.height() as usize], rgba.as_raw())) + }); + let Some(image) = image else { + ui.painter().rect_filled(rect, egui::CornerRadius::same(5), BRAND); + crate::icons::paint(ui.painter(), rect.shrink(rect.width() * 0.22), crate::icons::Icon::Logo, ACCENT_ON); + return; + }; + let id = egui::Id::new("figcraft_original_app_icon"); + let texture = ui.ctx().data(|data| data.get_temp::(id)).unwrap_or_else(|| { + let texture = ui.ctx().load_texture("figcraft-original-app-icon", image.clone(), egui::TextureOptions::LINEAR); + ui.ctx().data_mut(|data| data.insert_temp(id, texture.clone())); + texture + }); + ui.painter().image(texture.id(), rect, egui::Rect::from_min_max(egui::pos2(0.0, 0.0), egui::pos2(1.0, 1.0)), Color32::WHITE); +} + pub fn regular(size: f32) -> FontId { FontId::new(size, FontFamily::Proportional) } @@ -370,6 +455,49 @@ pub fn doc_c32(ctx: &Context, c: figcraft_color::Color) -> Color32 { mod tests { use super::*; + #[test] + fn canvas_overlays_take_the_green_that_stands_out_on_the_page() { + let ctx = Context::default(); + assert_eq!(canvas_accent(&ctx), ACCENT, "before any page shows"); + set_canvas_page(&ctx, Color32::from_rgb(0x1E, 0x1E, 0x1E)); + assert_eq!(canvas_accent(&ctx), BRAND, "the bright green on a dark page"); + set_canvas_page(&ctx, Color32::WHITE); + assert_eq!(canvas_accent(&ctx), ACCENT, "the darker green on a light page"); + set_canvas_page(&ctx, Color32::from_rgb(0xF5, 0xF5, 0xF5)); + assert_eq!(canvas_accent(&ctx), ACCENT); + // White text never sits on the bright green. + assert_eq!(under_white(BRAND), ACCENT); + assert_eq!(under_white(COMPONENT), COMPONENT); + } + + /// `fill` (premultiplied, maybe translucent) painted over the opaque `bg`. + fn over(fill: Color32, bg: Color32) -> Color32 { + let k = 1.0 - f32::from(fill.a()) / 255.0; + let mix = |f: u8, b: u8| (f32::from(f) + f32::from(b) * k).round().min(255.0) as u8; + Color32::from_rgb(mix(fill.r(), bg.r()), mix(fill.g(), bg.g()), mix(fill.b(), bg.b())) + } + + #[test] + fn disabled_and_selected_text_stay_readable_in_both_themes() { + for t in [Tokens::light(), Tokens::dark()] { + let theme = if t.dark { "dark" } else { "light" }; + // Disabled text (a disabled button's label, placeholders) on panels, fields and + // selected rows, still dimmer than secondary text. + for bg in [t.panel, t.field, t.hover, t.selected_row] { + let ratio = c(t.text_tertiary).contrast(c(bg)); + assert!(ratio >= 3.0, "{theme}: dim text {:?} on {bg:?} is {ratio:.2}:1", t.text_tertiary); + assert!(ratio < c(t.text_secondary).contrast(c(bg)), "{theme}: dim text reads dimmer than secondary text"); + } + // Selected text (renaming a page or layer, any text box) on the selection highlight. + let v = visuals(t); + for bg in [t.panel, t.field] { + let highlight = over(v.selection.bg_fill, bg); + let ratio = c(v.selection.stroke.color).contrast(c(highlight)); + assert!(ratio >= 3.0, "{theme}: selected text on {highlight:?} is {ratio:.2}:1"); + } + } + } + #[test] fn ui_font_draws_every_mac_shortcut_glyph() { figcraft_text::scan_system_fonts(); diff --git a/crates/ui-egui/src/titlebar_tests.rs b/crates/ui-egui/src/titlebar_tests.rs index e9224404..9f1916c1 100644 --- a/crates/ui-egui/src/titlebar_tests.rs +++ b/crates/ui-egui/src/titlebar_tests.rs @@ -158,6 +158,81 @@ fn tabs_and_new_tab_keep_their_clicks() { assert!(opened, "the new-tab button took no click"); } +#[test] +fn many_tabs_do_not_cover_the_window_buttons() { + let mut u = Ui::new(TitleBar::Custom); + for _ in 0..24 { + u.app.session.execute("file.new", &json!({})).unwrap(); + } + u.frame(vec![]); + let before = u.app.session.docs.len(); + u.click(pos2(SIZE.x - 222.0, 20.0)); + assert_eq!(u.app.session.docs.len(), before + 1, "the new-tab button must stay reachable"); + assert!(u.click(window_button(0)).contains(&ViewportCommand::Minimized(true))); + assert!(u.click(window_button(1)).contains(&ViewportCommand::Maximized(true))); + u.click(window_button(2)); + assert!(u.frame(vec![]).contains(&ViewportCommand::Close)); +} + +#[test] +fn close_button_on_inactive_tab_closes_it_without_switching() { + let mut u = Ui::new(TitleBar::Custom); + u.app.session.execute("file.new", &json!({})).unwrap(); + u.app.session.execute("file.new", &json!({})).unwrap(); + let active_uid = u.app.session.docs[u.app.session.active].uid; + let inactive_uid = u.app.session.docs[1].uid; + let close_button = pos2(270.0, 20.0); + u.frame(vec![Event::PointerMoved(close_button)]); + u.click(close_button); + assert!( + !u.app.session.docs.iter().any(|doc| doc.uid == inactive_uid), + "docs={}, active={}, last_error={:?}, target_dirty={}", + u.app.session.docs.len(), + u.app.session.active, + u.app.ui.last_error, + u.app.session.docs.iter().find(|doc| doc.uid == inactive_uid).is_some_and(|doc| doc.is_dirty()) + ); + assert_eq!(u.app.session.docs[u.app.session.active].uid, active_uid); +} + +#[test] +fn closing_a_tab_before_the_active_one_keeps_the_active_file() { + let mut u = Ui::new(TitleBar::Custom); + for _ in 0..3 { + u.app.session.execute("file.new", &json!({})).unwrap(); + } + u.app.session.execute("doc.switch", &json!({"index": 2})).unwrap(); + let active_uid = u.app.session.docs[u.app.session.active].uid; + let closed_uid = u.app.session.docs[0].uid; + let close_button = pos2(156.0, 20.0); + u.frame(vec![Event::PointerMoved(close_button)]); + u.click(close_button); + assert!(!u.app.session.docs.iter().any(|doc| doc.uid == closed_uid)); + assert_eq!(u.app.session.docs[u.app.session.active].uid, active_uid); +} + +#[test] +fn dragging_a_tab_along_the_bar_reorders_it_without_detaching() { + let mut u = Ui::new(TitleBar::Custom); + for name in ["Two", "Three", "Four"] { + u.app.session.execute("file.new", &json!({"name": name})).unwrap(); + } + let moved_uid = u.app.session.docs[0].uid; + let active_uid = u.app.session.docs[u.app.session.active].uid; + u.frame(vec![]); + let commands = u.drag(pos2(100.0, 20.0), vec2(310.0, 0.0)); + assert!(!commands.contains(&ViewportCommand::StartDrag), "{commands:?}"); + assert_eq!( + u.app.session.docs[2].uid, + moved_uid, + "order={:?}, active={}, error={:?}", + u.app.session.docs.iter().map(|d| d.uid).collect::>(), + u.app.session.active, + u.app.ui.last_error + ); + assert_eq!(u.app.session.docs[u.app.session.active].uid, active_uid); +} + #[test] fn a_system_title_bar_leaves_the_window_alone() { let mut u = Ui::new(TitleBar::Native); diff --git a/crates/ui-egui/src/toolbar.rs b/crates/ui-egui/src/toolbar.rs index 6da9badb..b95575b8 100644 --- a/crates/ui-egui/src/toolbar.rs +++ b/crates/ui-egui/src/toolbar.rs @@ -9,30 +9,54 @@ use crate::FigApp; use crate::a11y::{self, Nav, Region, Tip}; use crate::i18n::{tr, tr_noop, trf}; use crate::icons::{self, Icon}; -use crate::theme::{ACCENT, Tokens, regular}; +use crate::theme::{Tokens, regular}; + +/// Every mark in the toolbar row is painted in a box this size, so they share one optical size +/// and one stroke width (#737, #738). +const GLYPH: f32 = 18.0; +/// The flyout carets: smaller than the marks, but on a box whose stroke rounds to the same width +/// as theirs at 1×, 1.5× and 2× (a 10-point box drew them one physical pixel thinner). +const CARET: f32 = 14.0; + +/// `GLYPH`'s box centred in `r`. +fn glyph_rect(r: Rect) -> Rect { + Rect::from_center_size(r.center(), vec2(GLYPH, GLYPH)) +} struct Group { key: &'static str, /// 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), @@ -40,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 { @@ -73,71 +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) } -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; +/// 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 (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(10.0, 10.0)), 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); @@ -145,11 +235,11 @@ fn main_toolbar(app: &mut FigApp, ui: &mut Ui, canvas: Rect, t: &Tokens) { // The Actions button reads on while its panel is open; clicking it again closes it. let actions_open = id == "actions" && app.ui.palette.is_some(); if actions_open { - ui.painter().rect_filled(r, CornerRadius::same(5), ACCENT); + ui.painter().rect_filled(r, CornerRadius::same(5), t.accent); } else if resp.hovered() { ui.painter().rect_filled(r, CornerRadius::same(5), t.hover); } - icons::paint(ui.painter(), r.shrink(7.0), icon, if actions_open { Color32::WHITE } else { t.icon }); + icons::paint(ui.painter(), glyph_rect(r), icon, if actions_open { t.accent_on } else { t.icon }); if resp.clicked() { if id == "actions" { crate::menus::toggle_palette(&mut app.ui); @@ -157,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, @@ -181,25 +273,33 @@ fn main_toolbar(app: &mut FigApp, ui: &mut Ui, canvas: Rect, t: &Tokens) { } else if resp.hovered() { ui.painter().rect_filled(r, CornerRadius::same(6), t.hover); } - icons::paint(ui.painter(), r.shrink(9.0), *icon, if i == active { t.accent_text } else { t.icon_secondary }); + 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)); @@ -366,20 +466,20 @@ 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); }); a11y::toggle(&resp, tip, active); let bg = if active { - ACCENT + t.accent } else if resp.hovered() { t.hover } else { Color32::TRANSPARENT }; ui.painter().rect_filled(r, CornerRadius::same(5), bg); - icons::paint(ui.painter(), r.shrink(7.0), icon, if active { Color32::WHITE } else { t.icon }); + icons::paint(ui.painter(), glyph_rect(r), icon, if active { t.accent_on } else { t.icon }); resp } @@ -395,11 +495,46 @@ fn done_button(ui: &mut Ui, t: &Tokens, x: f32, cy: f32, tip: &str) -> bool { divider(ui, t, x, cy); let done = Rect::from_min_size(pos2(x + 12.0, cy - 14.0), vec2(DONE_W, 28.0)); let resp = ui.interact(done, ui.id().with("tool_done"), Sense::click()).tip(tip); - ui.painter().rect_filled(done, CornerRadius::same(6), if resp.hovered() { t.accent_hover } else { ACCENT }); - ui.painter().text(done.center(), Align2::CENTER_CENTER, tr("Done"), crate::theme::semibold(11.5), Color32::WHITE); + ui.painter().rect_filled(done, CornerRadius::same(6), if resp.hovered() { t.accent_hover } else { t.accent }); + ui.painter().text(done.center(), Align2::CENTER_CENTER, tr("Done"), crate::theme::semibold(11.5), t.accent_on); 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; @@ -438,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)) @@ -452,3 +596,52 @@ fn flyout_id(key: &str) -> egui::Id { pub fn pretty_shortcut(s: &str) -> String { figcraft_engine::keymap::display(s, crate::shortcuts::platform()) } + +#[cfg(test)] +mod tests { + use super::*; + use egui::Shape; + + /// The stroke widths `icon` paints with in a `size` box at `ppp` pixels per point. + fn widths(ppp: f32, icon: Icon, size: f32) -> Vec { + let ctx = egui::Context::default(); + let input = || { + let mut i = egui::RawInput::default(); + i.viewports.entry(egui::ViewportId::ROOT).or_default().native_pixels_per_point = Some(ppp); + i + }; + ctx.run_ui(input(), |_| {}).textures_delta.clear(); + let mut out = + ctx.run_ui(input(), |ui| icons::paint(ui.painter(), Rect::from_center_size(pos2(50.0, 50.0), vec2(size, size)), icon, Color32::WHITE)); + out.textures_delta.clear(); + assert_eq!(ctx.pixels_per_point(), ppp); + let mut w: Vec = out + .shapes + .iter() + .filter_map(|c| match &c.shape { + Shape::LineSegment { stroke, .. } => Some(stroke.width), + Shape::Path(p) => Some(p.stroke.width), + Shape::Rect(r) => Some(r.stroke.width), + _ => None, + }) + .filter(|w| *w > 0.0) + .collect(); + w.dedup(); + w + } + + /// The flyout carets and the marks beside them draw with one stroke width (#737), and the + /// mode switcher's marks are the tool marks' size (#738). + #[test] + fn toolbar_marks_share_one_stroke_width() { + for ppp in [1.0, 1.5, 2.0] { + let mark = widths(ppp, Icon::Rectangle, GLYPH); + assert_eq!(mark.len(), 1, "one width at {ppp}×: {mark:?}"); + assert_eq!(widths(ppp, Icon::Chevron, CARET), mark, "caret at {ppp}×"); + assert_eq!(widths(ppp, Icon::Code, GLYPH), mark, "mode mark at {ppp}×"); + for icon in [Icon::Frame, Icon::Text] { + assert_eq!(widths(ppp, icon, GLYPH), mark, "{icon:?} at {ppp}×"); + } + } + } +} 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 7740a9e0..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 } @@ -587,7 +583,7 @@ fn decoration_details(app: &mut FigApp, ui: &mut Ui, styles: &[TextStyle], apply if !auto && ui .add( - egui::Label::new(egui::RichText::new(tr("Auto")).font(regular(11.0)).color(Tokens::get(ui.ctx()).accent)) + egui::Label::new(egui::RichText::new(tr("Auto")).font(regular(11.0)).color(Tokens::get(ui.ctx()).accent_text)) .sense(Sense::click()), ) .on_hover_text(tip) @@ -713,7 +709,7 @@ fn variable_axes(app: &mut FigApp, ui: &mut Ui, ids: &[NodeId], styles: &[TextSt } if styles.iter().any(|s| !s.variations.is_empty()) { ui.add_space(8.0); - let r = ui.add(egui::Label::new(egui::RichText::new(tr("Reset to style")).font(regular(11.0)).color(t.accent)).sense(Sense::click())); + let r = ui.add(egui::Label::new(egui::RichText::new(tr("Reset to style")).font(regular(11.0)).color(t.accent_text)).sense(Sense::click())); if r.on_hover_text(tr("Back to the values the font style implies")).clicked() { app.run("text.setVariations", json!({"ids": ids, "variations": {}, "replace": true})); } @@ -789,7 +785,7 @@ fn axis_slider(ui: &mut Ui, id: Id, axis: &figcraft_text::Axis, value: Option, supported: match on { Some(true) => { ui.painter().rect_filled(b, CornerRadius::same(3), t.accent); - icons::paint(ui.painter(), b.shrink(1.5), Icon::Check, Color32::WHITE); + icons::paint(ui.painter(), b.shrink(1.5), Icon::Check, t.accent_on); } Some(false) => { ui.painter().rect_stroke(b, CornerRadius::same(3), Stroke::new(1.0, t.border_strong), StrokeKind::Inside); } None => { ui.painter().rect_filled(b, CornerRadius::same(3), t.accent); - ui.painter().line_segment([pos2(b.min.x + 3.5, b.center().y), pos2(b.max.x - 3.5, b.center().y)], Stroke::new(1.5, Color32::WHITE)); + ui.painter().line_segment([pos2(b.min.x + 3.5, b.center().y), pos2(b.max.x - 3.5, b.center().y)], Stroke::new(1.5, t.accent_on)); } } let col = if supported { t.text } else { t.text_tertiary }; @@ -1025,7 +1021,7 @@ fn lead_field(ui: &mut Ui, id: Id, glyph: LeadGlyph, value: &str, width: f32, ti crate::a11y::text(&resp, tip, value); ui.painter().rect_filled(rect, CornerRadius::same(5), t.field); if editing.is_some() { - ui.painter().rect_stroke(rect, CornerRadius::same(5), Stroke::new(1.0, t.accent), StrokeKind::Inside); + ui.painter().rect_stroke(rect, CornerRadius::same(5), Stroke::new(1.0, crate::theme::ACCENT), StrokeKind::Inside); } else if resp.hovered() { ui.painter().rect_stroke(rect, CornerRadius::same(5), Stroke::new(1.0, t.field_hover_border), StrokeKind::Inside); } @@ -1274,7 +1270,7 @@ pub(crate) fn font_picker(ui: &mut Ui, current: Option<&str>, just_opened: bool) if hovered { ui.painter().rect_filled(rect, CornerRadius::same(5), t.accent); } - let col = if hovered { Color32::WHITE } else { t.text }; + let col = if hovered { t.accent_on } else { t.text }; if current.is_some_and(|c| c.eq_ignore_ascii_case(f)) { icons::paint( ui.painter(), diff --git a/crates/ui-egui/src/ui_scale.rs b/crates/ui-egui/src/ui_scale.rs new file mode 100644 index 00000000..6b275a07 --- /dev/null +++ b/crates/ui-egui/src/ui_scale.rs @@ -0,0 +1,116 @@ +//! The interface size (Preferences ▸ Interface size, `pref.uiScale`): how the saved choice becomes +//! egui's zoom factor. +//! +//! egui draws at `pixels_per_point = zoom factor × the system's scale`. The size is relative to the +//! system's scale, so it is the zoom itself: 100 % is the system's own size on every screen (native +//! on a Retina Mac too), 125 % a quarter larger. A size above 100 % is held back while the window is +//! too small to lay the interface out at it; the saved choice never changes. + +/// The smallest window, in points, the interface is laid out for: the main window's minimum size. +pub const FIT_POINTS: (f32, f32) = (800.0, 500.0); + +/// The zoom factors egui's own settings window allows. +const ZOOM_RANGE: (f32, f32) = (0.1, 10.0); + +/// What to give egui for a saved choice on this screen and window. +#[derive(Clone, Copy, Debug, PartialEq)] +pub struct Plan { + /// For `Context::set_zoom_factor`: the size drawn, relative to the system's scale. + pub zoom: f32, + /// Physical pixels per point once drawn (the zoom times the system's scale). + pub drawn: f32, + /// The window is too small for the size chosen, so a smaller one is drawn. + pub limited: bool, +} + +/// The plan for `choice` (relative to the system's scale), the system's scale `native` and the +/// window's size in physical pixels `window_px`. An unknown, zero or not finite value reads as no +/// information: a size of 1, a system scale of 1 and a window of any size. +pub fn plan(choice: f64, native: Option, window_px: Option<(f32, f32)>) -> Plan { + let native = native.filter(|n| n.is_finite() && *n > 0.0).unwrap_or(1.0); + let want = Some(choice as f32).filter(|c| c.is_finite() && *c > 0.0).unwrap_or(1.0); + // The largest zoom at which the window still holds the smallest layout. + let fit = window_px + .filter(|(w, h)| w.is_finite() && h.is_finite() && *w > 0.0 && *h > 0.0) + .map(|(w, h)| (w / FIT_POINTS.0).min(h / FIT_POINTS.1) / native); + // Never below 100 % (or the size asked, when that is smaller): a tiny window doesn't shrink the interface further. + let zoom = match fit { + Some(fit) if want > fit => fit.max(want.min(1.0)), + _ => want, + } + .clamp(ZOOM_RANGE.0, ZOOM_RANGE.1); + Plan { zoom, drawn: zoom * native, limited: zoom < want - 0.005 } +} + +/// A size as the percentage Preferences shows (1.25 → 125). +pub fn percent(scale: f32) -> u32 { + (scale * 100.0).round().clamp(0.0, 100_000.0) as u32 +} + +/// The sizes Preferences offers, relative to the system's scale (`pref.uiScale` takes up to 3). +pub const SIZES: [f64; 8] = [0.75, 0.9, 1.0, 1.1, 1.25, 1.5, 1.75, 2.0]; + +#[cfg(test)] +mod tests { + use super::*; + + const BIG: Option<(f32, f32)> = Some((20000.0, 12000.0)); + + #[test] + fn a_size_is_relative_to_the_systems_scale_on_every_screen() { + // Windows 100/125/150/175 %, Retina 2×, a bare X11 window manager's guessed 2×, a 3× screen. + for native in [1.0, 1.25, 1.5, 1.75, 2.0, 3.0] { + let p = plan(1.0, Some(native), BIG); + assert_eq!((p.zoom, p.drawn, p.limited), (1.0, native, false), "100 % is the system's own size on {native}"); + for size in SIZES { + let p = plan(size, Some(native), BIG); + assert!((p.zoom - size as f32).abs() < 1e-5, "{size} on {native}: zoom {}", p.zoom); + assert!((p.drawn - size as f32 * native).abs() < 1e-4, "{size} on {native}: drawn {}", p.drawn); + assert!(!p.limited, "{size} on {native}"); + } + } + } + + #[test] + fn a_missing_or_unusable_scale_reads_as_1_and_never_gives_egui_a_bad_zoom() { + for native in [None, Some(0.0), Some(-2.0), Some(f32::NAN), Some(f32::INFINITY)] { + for choice in [1.0, 1.5, f64::NAN, f64::INFINITY, -1.0, 0.0, 1e300] { + let p = plan(choice, native, Some((f32::NAN, 0.0))); + assert!(p.zoom.is_finite() && (ZOOM_RANGE.0..=ZOOM_RANGE.1).contains(&p.zoom), "{choice:?} {native:?}: {p:?}"); + assert!(p.drawn.is_finite() && p.drawn > 0.0, "{choice:?} {native:?}: {p:?}"); + } + } + // A choice that isn't a usable number is the system's own size. + assert_eq!(plan(f64::NAN, Some(2.0), BIG), plan(1.0, Some(2.0), BIG)); + } + + #[test] + fn a_small_window_holds_a_big_size_back_without_going_below_100() { + // The minimum window on a Retina Mac: 800 × 500 points is 1600 × 1000 pixels, which holds 100 % only. + let min = Some((1600.0, 1000.0)); + let p = plan(1.5, Some(2.0), min); + assert_eq!((p.zoom, p.drawn, p.limited), (1.0, 2.0, true)); + assert!(!plan(1.0, Some(2.0), min).limited); + // A larger window on a 1× screen: 1200 × 750 pixels hold 150 %. + let p = plan(2.0, Some(1.0), Some((1200.0, 750.0))); + assert_eq!((p.zoom, p.limited), (1.5, true)); + // A window smaller than the minimum (a tiling window manager): never below 100 %, and never + // below a smaller size the person chose. + assert_eq!(plan(2.0, Some(1.0), Some((400.0, 250.0))).zoom, 1.0); + let p = plan(0.75, Some(1.0), Some((400.0, 250.0))); + assert_eq!((p.zoom, p.limited), (0.75, false)); + } + + #[test] + fn the_limit_does_not_depend_on_the_zoom_so_it_cannot_flicker() { + // The window's size in pixels is the same whatever zoom is drawn now, so the plan for it is too. + let px = Some((1700.0, 1100.0)); + assert_eq!(plan(2.5, Some(2.0), px), plan(2.5, Some(2.0), px)); + } + + #[test] + fn percentages_round_like_the_menu_shows_them() { + assert_eq!([percent(0.75), percent(0.9), percent(1.1), percent(1.25), percent(2.0), percent(1.7999)], [75, 90, 110, 125, 200, 180]); + assert_eq!([percent(f32::NAN), percent(-1.0)], [0, 0]); + } +} diff --git a/crates/ui-egui/src/ui_scale_tests.rs b/crates/ui-egui/src/ui_scale_tests.rs new file mode 100644 index 00000000..4f2a478b --- /dev/null +++ b/crates/ui-egui/src/ui_scale_tests.rs @@ -0,0 +1,223 @@ +//! The interface size, driven through egui headless on screens of different system scales: 100 % +//! leaves the system's scale alone, another size is relative to it on every screen, and the +//! Preferences drop-down tells a screen reader its name and value. +//! +//! The test window's size is fixed in points, so it grows with the zoom here; the limit for a small +//! window is tested on its own in `ui_scale::tests`, and the sizes below stay inside it. + +use egui::accesskit::Role; +use egui::{RawInput, ViewportId, ViewportInfo}; +use figcraft_engine::Session; +use serde_json::json; + +use crate::layers_drag_tests::Ui; + +/// One frame on a screen whose system scale is `native` (a real window reports it every frame). +fn frame_at(u: &mut Ui, native: f32) -> egui::FullOutput { + let mut raw = RawInput::default(); + raw.viewports.insert(ViewportId::ROOT, ViewportInfo { native_pixels_per_point: Some(native), ..Default::default() }); + u.frame_raw(raw) +} + +/// Frames on that screen, enough for a new zoom to take effect. +fn settle(u: &mut Ui, native: f32) { + for _ in 0..4 { + frame_at(u, native); + } +} + +const SCREENS: [f32; 5] = [1.0, 1.25, 1.5, 2.0, 3.0]; + +#[test] +fn by_default_the_interface_is_the_systems_own_size() { + for native in SCREENS { + let mut u = Ui::new(Session::new()); + settle(&mut u, native); + assert_eq!(u.ctx.zoom_factor(), 1.0, "{native}"); + assert_eq!(u.ctx.pixels_per_point(), native, "{native}"); + } +} + +#[test] +fn a_size_is_relative_to_the_systems_scale_on_every_screen_and_auto_is_100() { + for native in SCREENS { + let mut u = Ui::new(Session::new()); + for size in [0.75_f64, 0.9, 1.25, 1.5] { + assert!(u.app.run("pref.uiScale", json!({"scale": size})).is_some()); + settle(&mut u, native); + let drawn = u.ctx.pixels_per_point(); + assert!((drawn - size as f32 * native).abs() < 1e-4, "{size} on a {native}× screen drew at {drawn}"); + assert!((u.ctx.zoom_factor() - size as f32).abs() < 1e-5, "{size} on {native}"); + assert!(!u.app.ui.ui_scale.limited, "{size} on {native}"); + } + assert!(u.app.run("pref.uiScale", json!({"scale": "auto"})).is_some()); + settle(&mut u, native); + assert_eq!((u.ctx.zoom_factor(), u.ctx.pixels_per_point()), (1.0, native), "{native}"); + } +} + +#[test] +fn the_interface_size_menu_has_a_name_a_value_and_a_state_for_a_screen_reader() { + let mut u = Ui::new(Session::new()); + u.app.ui.preferences = true; + u.ctx.enable_accesskit(); + let combo = |u: &mut Ui| { + settle(u, 2.0); + let out = frame_at(u, 2.0); + let nodes: Vec<_> = out.platform_output.accesskit_update.expect("an AccessKit update").nodes.into_iter().map(|(_, n)| n).collect(); + let n = nodes.iter().find(|n| n.role() == Role::ComboBox && n.label() == Some("Interface size")).expect("an Interface size menu"); + (n.value().map(str::to_owned), n.is_expanded()) + }; + // 100 % is the system's own size, whatever it is (2× here). + assert_eq!(combo(&mut u), (Some("100%".to_owned()), Some(false))); + u.app.run("pref.uiScale", json!({"scale": 1.25})); + assert_eq!(combo(&mut u), (Some("125%".to_owned()), Some(false))); +} + +/// Every window size, system scale and interface size, drawn the way a real window is: the window is +/// a fixed number of physical pixels, and its size in points is that divided by the scale drawn now +/// (egui-winit does the same each frame), so a bigger size shrinks the window in points. +/// +/// What a fixed size promises: whatever it draws at above 100 %, the window still holds the smallest +/// layout the interface is made for (`ui_scale::FIT_POINTS`), and the panels leave room for a canvas. +#[test] +fn every_window_size_system_scale_and_interface_size_lays_out_inside_the_window() { + use crate::ui_scale::{FIT_POINTS, SIZES}; + use crate::{FigApp, NoServices}; + use egui::{Pos2, Rect, vec2}; + + // Window sizes in points: the main window's minimum, common laptop and desktop screens up to 5K, + // and a short wide strip. + let windows: [(f32, f32); 10] = [ + (800.0, 500.0), + (1024.0, 640.0), + (1280.0, 720.0), + (1366.0, 768.0), + (1440.0, 900.0), + (1920.0, 1080.0), + (2560.0, 1440.0), + (3840.0, 2160.0), + (5120.0, 2880.0), + (2560.0, 520.0), + ]; + + for native in [1.0_f32, 1.25, 1.5, 1.75, 2.0, 3.0] { + for (w, h) in windows { + let px = vec2(w * native, h * native); + for want in SIZES { + let case = format!("{w}x{h} pt at {native}x, {want}"); + let mut app = FigApp::new(Session::new(), Box::new(NoServices)); + assert!(app.run("pref.uiScale", json!({"scale": want})).is_some(), "{case}"); + let ctx = egui::Context::default(); + let mut time = 0.0; + let mut seen = Vec::new(); + for _ in 0..8 { + time += 1.0 / 60.0; + let ppp = ctx.pixels_per_point(); + let mut raw = RawInput { screen_rect: Some(Rect::from_min_size(Pos2::ZERO, px / ppp)), time: Some(time), ..Default::default() }; + raw.viewports.insert(ViewportId::ROOT, ViewportInfo { native_pixels_per_point: Some(native), ..Default::default() }); + let mut out = ctx.run_ui(raw, |ui| app.ui(ui)); + out.textures_delta.clear(); + seen.push((ctx.pixels_per_point(), ctx.content_rect().size())); + } + // It settles within a few frames and then stays put: no flicker between two sizes. + let (ppp, content) = seen[seen.len() - 1]; + assert_eq!(seen[seen.len() - 2].0, ppp, "{case}: still changing"); + assert!(ppp.is_finite() && ppp > 0.0 && content.x.is_finite() && content.y.is_finite(), "{case}: {ppp} {content:?}"); + let plan = app.ui.ui_scale; + assert!((ppp - plan.drawn).abs() < 1e-3, "{case}: egui draws at {ppp}, the plan said {}", plan.drawn); + // The size drawn, relative to the system's scale. + let zoom = ppp / native; + let want = want as f32; + assert_eq!(plan.limited, zoom < want - 0.005, "{case}: limited flag"); + // Above 100 % the window holds the smallest layout; at or below it there is nothing more to give up. + if zoom > 1.001 { + assert!(content.x >= FIT_POINTS.0 - 0.5 && content.y >= FIT_POINTS.1 - 0.5, "{case}: window is {content:?} points at {ppp}"); + } + // It never draws bigger than asked, and only a window too small for it draws smaller. + assert!(zoom <= want + 1e-3, "{case}"); + if !plan.limited { + assert!((zoom - want).abs() < 1e-3, "{case}"); + } + let panels = &app.inspect()["panels"]; + let side = panels["left"].as_f64().unwrap_or(0.0) + panels["right"].as_f64().unwrap_or(0.0); + assert!(side < f64::from(content.x) - 100.0, "{case}: the panels ({side} pt) leave no canvas in {content:?}"); + } + } + } +} + +/// The interface size is reachable and changeable without a pointer: Tab reaches the menu, Enter +/// opens it, the arrows and Tab move through its entries, Enter picks one and closes the menu, and +/// Escape closes it alone, without changing anything and without closing Preferences (a second +/// Escape does). +#[test] +fn the_interface_size_menu_works_from_the_keyboard() { + use crate::a11y::focused_name; + 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_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; + while focused_name(&u.ctx) != "Interface size" { + assert!(tabs < 140, "Tab never reaches the Interface size menu (focus: {:?})", focused_name(&u.ctx)); + press(u, Key::Tab); + tabs += 1; + } + press(u, Key::Enter); + assert!(Popup::is_any_open(&u.ctx), "Enter opens the menu"); + }; + let chosen = |u: &Ui| u.app.inspect()["uiScale"]["choice"].clone(); + + let mut u = Ui::new(Session::new()); + u.app.ui.preferences = true; + u.ctx.enable_accesskit(); + settle(&mut u, 1.0); + assert_eq!(chosen(&u), json!(1.0)); + + // Escape closes the menu and nothing else: the size is unchanged and Preferences stays open. + open(&mut u); + press(&mut u, Key::Escape); + u.frame(vec![]); + assert!(!Popup::is_any_open(&u.ctx)); + assert!(u.app.ui.preferences, "the first Escape closes the menu only"); + assert_eq!(chosen(&u), json!(1.0)); + + // The arrows enter the menu on its first size and go down through the sizes (75, 90, 100, 110, + // 125); Enter picks the one that has the keyboard and closes the menu. + open(&mut u); + press(&mut u, Key::ArrowDown); + assert_eq!(focused_name(&u.ctx), "75%"); + for _ in 0..4 { + 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"); + assert!(u.app.ui.preferences, "picking a size leaves Preferences open"); + // With no menu open, Escape closes Preferences as before. + press(&mut u, Key::Escape); + assert!(!u.app.ui.preferences); +} diff --git a/crates/ui-egui/src/variable_ui.rs b/crates/ui-egui/src/variable_ui.rs index e6cc6860..39c5396e 100644 --- a/crates/ui-egui/src/variable_ui.rs +++ b/crates/ui-egui/src/variable_ui.rs @@ -17,7 +17,7 @@ use crate::a11y::Tip; use crate::i18n::tr; use crate::icons::{self, Icon}; use crate::theme::{Tokens, regular, semibold}; -use crate::widgets::{self, BindEvent, Edit, FieldEdit, Lead}; +use crate::widgets::{self, BindEvent, Edit, Empty, FieldEdit, Lead}; /// What picking a variable does. #[derive(Clone)] @@ -258,7 +258,23 @@ pub fn number_field_or( lead: Lead, suffix: &str, ) -> Option { - number_field_edit(app, ui, ids, field, id, value, width, empty, lead, suffix)?.settled() + number_field_edit(app, ui, ids, field, id, value, width, Empty::Text(empty), lead, suffix)?.settled() +} + +/// [`number_field_or`] for a field where 0 is its automatic value (a layout grid's width or +/// count): typing the word for Auto sets 0 ([`Empty::Auto`]). +pub fn number_field_auto( + app: &mut FigApp, + ui: &mut Ui, + ids: &[NodeId], + field: &str, + id: Id, + value: Option, + width: f32, + lead: Lead, + suffix: &str, +) -> Option { + number_field_edit(app, ui, ids, field, id, value, width, Empty::Auto, lead, suffix)?.settled() } /// [`number_field`] over several layers: a relative edit of mixed values comes back as one for @@ -274,7 +290,7 @@ pub fn number_field_mixed( suffix: &str, width: f32, ) -> Option { - number_field_edit(app, ui, ids, field, id, value, width, tr("Mixed"), lead, suffix) + number_field_edit(app, ui, ids, field, id, value, width, Empty::Text(tr("Mixed")), lead, suffix) } fn number_field_edit( @@ -285,7 +301,7 @@ fn number_field_edit( id: Id, value: Option, width: f32, - empty: &str, + empty: Empty<'_>, lead: Lead, suffix: &str, ) -> Option { diff --git a/crates/ui-egui/src/variants_discovery_tests.rs b/crates/ui-egui/src/variants_discovery_tests.rs new file mode 100644 index 00000000..e153d982 --- /dev/null +++ b/crates/ui-egui/src/variants_discovery_tests.rs @@ -0,0 +1,111 @@ +//! Combine as variants and Add variant where people look for them (#903), driven through egui +//! headless: the Design panel offers Combine for two or more main components and Add variant for +//! a lone one, and the canvas / layers context menu lists Combine only when it applies. + +use egui::Pos2; +use figcraft_doc::{NodeId, NodeKind}; +use figcraft_engine::Session; +use serde_json::json; + +use crate::layers_drag_tests::Ui; +use crate::live_tests::{flat, has_text, text_center}; +use crate::present_tests::id; + +const COMBINE: &str = "Combine as variants"; + +/// A session with lone main components named A, B and C side by side, and a frame. Returns +/// (session, [a, b, c], frame). +fn doc() -> (Session, [NodeId; 3], NodeId) { + let mut s = Session::new(); + let mut comps = Vec::new(); + for (k, name) in ["A", "B", "C"].iter().enumerate() { + let f = id(&s.execute("frame.create", &json!({"x": k * 160, "y": 0, "width": 120, "height": 40, "name": name})).unwrap()); + comps.push(id(&s.execute("component.create", &json!({"ids": [f]})).unwrap())); + } + let frame = id(&s.execute("frame.create", &json!({"x": 0, "y": 200, "width": 60, "height": 60})).unwrap()); + (s, [comps[0], comps[1], comps[2]], frame) +} + +fn select(u: &mut Ui, ids: &[NodeId]) { + u.app.session.execute("select.set", &json!({"ids": ids})).unwrap(); + u.frame(vec![]); +} + +/// The text painted this frame. +fn shows(u: &mut Ui, s: &str) -> Option { + let shapes = flat(&u.frame_output(vec![]).shapes); + has_text(&shapes, s).then(|| text_center(&shapes, s)) +} + +fn click(u: &mut Ui, p: Pos2) { + u.button(p, true); + u.button(p, false); +} + +fn set_of(u: &Ui, c: NodeId) -> Option { + let doc = u.app.session.doc().unwrap(); + doc.node(c).and_then(|n| n.parent).filter(|p| doc.node(*p).is_some_and(|n| matches!(n.kind, NodeKind::ComponentSet { .. }))) +} + +fn undo_len(u: &Ui) -> usize { + u.app.session.ds().unwrap().history.undo.len() +} + +#[test] +fn the_design_panel_combines_two_selected_components() { + let (s, [a, b, _], _) = doc(); + let mut u = Ui::new(s); + select(&mut u, &[a, b]); + let at = shows(&mut u, COMBINE).expect("the Design panel offers Combine as variants"); + let undo = undo_len(&u); + click(&mut u, at); + let set = set_of(&u, a).expect("combined into a set"); + assert_eq!(set_of(&u, b), Some(set)); + assert_eq!(u.app.session.selection(), vec![set]); + assert_eq!(undo_len(&u), undo + 1); + assert!(u.app.ui.last_error.is_none(), "{:?}", u.app.ui.last_error); + assert!(shows(&mut u, COMBINE).is_none(), "a set selected: nothing to combine"); +} + +#[test] +fn the_design_panel_offers_combine_only_when_it_applies() { + // The cases are the engine's (`can_combine_variants`); this pins the panel to it. + let (s, [a, ..], frame) = doc(); + let mut u = Ui::new(s); + select(&mut u, &[a, frame]); + assert!(shows(&mut u, COMBINE).is_none(), "a component and a frame"); +} + +#[test] +fn the_design_panel_adds_a_variant_to_a_lone_component() { + let (s, [a, ..], _) = doc(); + let mut u = Ui::new(s); + select(&mut u, &[a]); + let at = shows(&mut u, "Add variant").expect("a lone main component offers Add variant"); + click(&mut u, at); + let set = set_of(&u, a).expect("the component became a set"); + assert_eq!(u.app.session.doc().unwrap().node(set).unwrap().children.len(), 2); + assert!(u.app.ui.last_error.is_none(), "{:?}", u.app.ui.last_error); +} + +#[test] +fn the_context_menu_lists_combine_for_two_selected_components() { + let (s, [a, b, _], _) = doc(); + let mut u = Ui::new(s); + // One component: no Combine, Create component as before. + select(&mut u, &[a]); + u.app.ui.context_menu = Some((Pos2::new(700.0, 300.0), vec![a])); + assert!(shows(&mut u, COMBINE).is_none()); + assert!(shows(&mut u, "Create component").is_some()); + u.app.ui.context_menu = None; + // Two components, right-clicked on one layer row (the menu gets only that row): judged on + // the selection, which is what the command combines. + select(&mut u, &[a, b]); + u.app.ui.context_menu = Some((Pos2::new(700.0, 300.0), vec![a])); + u.frame(vec![]); + let at = shows(&mut u, COMBINE).expect("the context menu offers Combine as variants"); + click(&mut u, at); + let set = set_of(&u, a).expect("combined into a set"); + assert_eq!(set_of(&u, b), Some(set)); + assert!(u.app.ui.context_menu.is_none(), "the menu closes"); +} diff --git a/crates/ui-egui/src/web_clipboard_tests.rs b/crates/ui-egui/src/web_clipboard_tests.rs index 26389ff4..bda57373 100644 --- a/crates/ui-egui/src/web_clipboard_tests.rs +++ b/crates/ui-egui/src/web_clipboard_tests.rs @@ -141,6 +141,24 @@ fn text_copied_elsewhere_after_our_copy_still_pastes_as_text() { assert_eq!(p.count(text), 1, "newer text from elsewhere becomes a text layer"); } +#[test] +fn copying_text_layers_puts_their_text_on_the_clipboard() { + // #808: other apps get a text layer's text, not its SVG; FigCraft still pastes the layers. + let mut p = Page::new(); + let a = p.app.session.execute("text.create", &json!({"text": "Hello", "x": 0, "y": 200})).unwrap(); + let b = p.app.session.execute("text.create", &json!({"text": "World", "x": 0, "y": 300})).unwrap(); + p.app.session.execute("select.set", &json!({"ids": [a["id"], b["id"]]})).unwrap(); + p.frame(vec![]); + p.copy(); + assert_eq!(p.clipboard, "Hello\nWorld", "one line per text layer"); + p.paste(); + assert_eq!(p.count(text), 4, "the copied text layers were pasted, not one layer of their text"); + let r = p.app.session.doc().unwrap().nodes().find(|n| rect(&n.kind)).map(|n| n.id.0).unwrap(); + p.app.session.execute("select.set", &json!({"ids": [r, a["id"]]})).unwrap(); + p.copy(); + assert!(p.clipboard.contains(" { } } +/// What a number field shows when it has no single value, and whether that word types 0. +#[derive(Clone, Copy)] +pub enum Empty<'a> { + /// Dim text that is only a label ("Mixed", or "Auto" where 0 means something else). + Text(&'a str), + /// Auto, in the UI's language: 0 is the field's automatic value, so the word types 0. + Auto, +} + +impl<'a> Empty<'a> { + /// The text shown when the field has no value. + fn text(self) -> &'a str { + match self { + Empty::Text(s) => s, + Empty::Auto => tr("Auto"), + } + } +} + /// Result of a field edit. pub enum FieldEdit { /// Committed a new value (Enter, Tab or focus loss; an arrow key's step). @@ -157,18 +176,19 @@ pub fn number_field(ui: &mut Ui, id: Id, lead: Lead, value: Option, suffix: /// [`number_field`] over several layers: without a single value (`value` None, "Mixed") a relative /// edit comes back as one for each layer ([`Edit::Each`]). pub fn number_field_mixed(ui: &mut Ui, id: Id, lead: Lead, value: Option, suffix: &str, width: f32, step: f64) -> Option { - number_field_bindable(ui, id, lead, value, suffix, width, step, None, tr("Mixed")).0 + number_field_bindable(ui, id, lead, value, suffix, width, step, None, Empty::Text(tr("Mixed"))).0 } /// [`number_field`] that shows `empty` (dim) for no value instead of "Mixed" (e.g. "Auto"): there /// is no value to edit relative to, so `+4` is 4. pub fn number_field_or(ui: &mut Ui, id: Id, value: Option, empty: &str, width: f32, step: f64) -> Option { - number_field_impl(ui, id, Lead::None, value, "", width, step, None, empty).0?.settled() + number_field_impl(ui, id, Lead::None, value, "", width, step, None, Empty::Text(empty)).0?.settled() } /// [`number_field`] that can bind a variable: `binding` None = not bindable, `Some(None)` = shows /// a variable button on hover, `Some(Some(name))` = bound (shows the name; click to change, the -/// detach button unbinds). No value shows `empty` ("Mixed", "Auto"). +/// detach button unbinds). No value shows `empty` ("Mixed", "Auto"); [`Empty::Auto`] also takes +/// the word for Auto as 0. pub fn number_field_bindable( ui: &mut Ui, id: Id, @@ -178,7 +198,7 @@ pub fn number_field_bindable( width: f32, step: f64, binding: Option>, - empty: &str, + empty: Empty<'_>, ) -> (Option, Option) { number_field_impl(ui, id, lead, value, suffix, width, step, binding, empty) } @@ -192,7 +212,7 @@ fn number_field_impl( width: f32, step: f64, binding: Option>, - empty: &str, + empty: Empty<'_>, ) -> (Option, Option) { let t = Tokens::get(ui.ctx()); let (rect, _) = ui.allocate_exact_size(vec2(width, FIELD_H), Sense::hover()); @@ -210,14 +230,14 @@ fn number_field_impl( let editing_id = id.with("editing"); let mut editing: Option = ui.data(|d| d.get_temp(editing_id)); let field_resp = ui.interact(edit_rect, id.with("field"), Sense::click()); - a11y::number(&field_resp, lead.name(), value, suffix, empty); + a11y::number(&field_resp, lead.name(), value, suffix, empty.text()); a11y::ring_around(ui, rect, &field_resp); // The label scrubs with the pointer; the keyboard stops at the value only. let lead_resp = ui.interact(lead_rect, id.with("lead"), Sense::CLICK | Sense::DRAG); let hovered = field_resp.hovered() || lead_resp.hovered() || editing.is_some(); ui.painter().rect_filled(rect, CornerRadius::same(5), t.field); if editing.is_some() { - ui.painter().rect_stroke(rect, CornerRadius::same(5), Stroke::new(1.0, t.accent), StrokeKind::Inside); + ui.painter().rect_stroke(rect, CornerRadius::same(5), Stroke::new(1.0, crate::theme::ACCENT), StrokeKind::Inside); } else if hovered { ui.painter().rect_stroke(rect, CornerRadius::same(5), Stroke::new(1.0, t.field_hover_border), StrokeKind::Inside); } @@ -259,10 +279,21 @@ fn number_field_impl( }; // A relative edit of no single value goes to each layer's own. let set = |v: f64| Edit::All(FieldEdit::Set(v)); - let typed = |text: &str| match (parse(text, suffix == "%")?, value) { - (Typed::Value(v), _) => Some(set(v)), - (Typed::Rel(r), Some(cur)) => r.apply(cur).map(set), - (Typed::Rel(r), None) => Some(Edit::Each(r)), + let typed = |text: &str| { + // A field where 0 is its automatic value takes the word for Auto as input too: the + // placeholder's own text (so it is what the field shows, in the UI's language) or the + // English word. + if let Empty::Auto = empty { + let t = text.trim(); + if t == empty.text() || t.eq_ignore_ascii_case("auto") { + return Some(set(0.0)); + } + } + match (parse(text, suffix == "%")?, value) { + (Typed::Value(v), _) => Some(set(v)), + (Typed::Rel(r), Some(cur)) => r.apply(cur).map(set), + (Typed::Rel(r), None) => Some(Edit::Each(r)), + } }; // With the keyboard on the field (Tab) and nothing typed yet, the arrows step it as it is. if editing.is_none() @@ -349,7 +380,7 @@ fn number_field_impl( None => { let s = match value { Some(v) => format!("{}{}", fmt_num(v), suffix), - None => empty.to_string(), + None => empty.text().to_string(), }; let col = if value.is_some() { t.text } else { t.text_secondary }; // A value too long for its room ends in "…", so a cut number never reads as another. @@ -507,7 +538,7 @@ fn text_field_in(ui: &mut Ui, id: Id, value: &str, rect: Rect, placeholder: &str a11y::text(&resp, name, value); ui.painter().rect_filled(rect, CornerRadius::same(5), t.field); if editing.is_some() { - ui.painter().rect_stroke(rect, CornerRadius::same(5), Stroke::new(1.0, t.accent), StrokeKind::Inside); + ui.painter().rect_stroke(rect, CornerRadius::same(5), Stroke::new(1.0, crate::theme::ACCENT), StrokeKind::Inside); } else if resp.hovered() { ui.painter().rect_stroke(rect, CornerRadius::same(5), Stroke::new(1.0, t.field_hover_border), StrokeKind::Inside); } @@ -570,7 +601,7 @@ pub fn text_input(ui: &mut Ui, id: Id, text: &mut String, width: f32, hint: &str ui.painter().rect_filled(rect, CornerRadius::same(5), t.field); let r = field_editor(ui, rect, id, text, hint); if r.has_focus() { - ui.painter().rect_stroke(rect, CornerRadius::same(5), Stroke::new(1.0, t.accent), StrokeKind::Inside); + ui.painter().rect_stroke(rect, CornerRadius::same(5), Stroke::new(1.0, crate::theme::ACCENT), StrokeKind::Inside); } else if ui.rect_contains_pointer(rect) { ui.painter().rect_stroke(rect, CornerRadius::same(5), Stroke::new(1.0, t.field_hover_border), StrokeKind::Inside); } @@ -974,7 +1005,7 @@ pub fn checkbox(ui: &mut Ui, on: bool, label: &str) -> bool { let b = Rect::from_min_size(pos2(rect.min.x, rect.center().y - 7.0), vec2(14.0, 14.0)); if on { ui.painter().rect_filled(b, CornerRadius::same(3), t.accent); - icons::paint(ui.painter(), b.shrink(1.5), Icon::Check, Color32::WHITE); + icons::paint(ui.painter(), b.shrink(1.5), Icon::Check, t.accent_on); } else { ui.painter().rect_stroke(b, CornerRadius::same(3), Stroke::new(1.0, t.border_strong), StrokeKind::Inside); } @@ -990,35 +1021,35 @@ pub fn radio(ui: &mut Ui, on: bool, label: &str) -> bool { let c = pos2(rect.min.x + 7.0, rect.center().y); if on { ui.painter().circle_filled(c, 7.0, t.accent); - ui.painter().circle_filled(c, 2.5, Color32::WHITE); + ui.painter().circle_filled(c, 2.5, t.accent_on); } else { - ui.painter().circle_stroke(c, 6.5, Stroke::new(1.0, if resp.hovered() { t.accent } else { t.border_strong })); + ui.painter().circle_stroke(c, 6.5, Stroke::new(1.0, if resp.hovered() { crate::theme::ACCENT } else { t.border_strong })); } ui.painter().text(pos2(rect.min.x + 22.0, rect.center().y), Align2::LEFT_CENTER, label, regular(11.0), t.text); resp.clicked() } /// A text button: `primary` is filled with the accent colour, otherwise outlined. A disabled -/// button is dimmed and never reports clicks. +/// button is grey with dim text (still readable) and never reports clicks. pub fn button(ui: &mut Ui, label: &str, primary: bool, enabled: bool) -> Response { let t = Tokens::get(ui.ctx()); let galley = ui.painter().layout_no_wrap(label.to_string(), regular(11.0), t.text); let sense = if enabled { Sense::click() } else { Sense::hover() }; let (rect, resp) = ui.allocate_exact_size(vec2(galley.size().x + 24.0, FIELD_H), sense); a11y::button(&resp, label); - let hot = enabled && resp.hovered(); - let (fill, text) = match (primary, hot) { - (true, false) => (t.accent, Color32::WHITE), - (true, true) => (t.accent_hover, Color32::WHITE), - (false, false) => (Color32::TRANSPARENT, t.text), - (false, true) => (t.hover, t.text), + let (fill, text) = match (enabled, primary, resp.hovered()) { + (false, true, _) => (t.field, t.text_tertiary), + (false, false, _) => (Color32::TRANSPARENT, t.text_tertiary), + (true, true, false) => (t.accent, t.accent_on), + (true, true, true) => (t.accent_hover, t.accent_on), + (true, false, false) => (Color32::TRANSPARENT, t.text), + (true, false, true) => (t.hover, t.text), }; - let dim = |c: Color32| if enabled { c } else { c.gamma_multiply(0.4) }; - ui.painter().rect_filled(rect, CornerRadius::same(5), dim(fill)); + ui.painter().rect_filled(rect, CornerRadius::same(5), fill); if !primary { ui.painter().rect_stroke(rect, CornerRadius::same(5), Stroke::new(1.0, t.border_strong), StrokeKind::Inside); } - ui.painter().text(rect.center(), Align2::CENTER_CENTER, label, regular(11.0), dim(text)); + ui.painter().text(rect.center(), Align2::CENTER_CENTER, label, regular(11.0), text); resp } @@ -1046,7 +1077,7 @@ pub fn text_field_inline(ui: &mut Ui, id: Id, value: &str, rect: Rect, font: egu match buf { Some(mut text) => { ui.painter().rect_filled(rect.expand(2.0), CornerRadius::same(4), t.panel); - ui.painter().rect_stroke(rect.expand(2.0), CornerRadius::same(4), Stroke::new(1.0, t.accent), StrokeKind::Inside); + ui.painter().rect_stroke(rect.expand(2.0), CornerRadius::same(4), Stroke::new(1.0, crate::theme::ACCENT), StrokeKind::Inside); let mut child = ui.new_child(egui::UiBuilder::new().max_rect(rect)); let r = crate::bidi::single_line(&mut child, te_id, &mut text, font, rect.width()); a11y::own_focus(&r); diff --git a/crates/ui-egui/src/windows.rs b/crates/ui-egui/src/windows.rs index 1740c9a7..6afcf1b1 100644 --- a/crates/ui-egui/src/windows.rs +++ b/crates/ui-egui/src/windows.rs @@ -322,8 +322,33 @@ 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) { + // 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 +358,76 @@ 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 => save_then_close(app, ctx), + } +} + +/// 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(|e| e.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..a4e29212 100644 --- a/crates/ui-egui/src/windows_tests.rs +++ b/crates/ui-egui/src/windows_tests.rs @@ -170,3 +170,176 @@ 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_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 7af73fd6..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: @@ -153,6 +159,28 @@ figcraft-cli run start.json --out dashboard.figcraft figcraft-cli export dashboard.figcraft -o dashboard.png ``` +### Confining a run: `--automation-read-root DIR` / `--automation-write-root DIR` + +`info`, `export`, `convert`, `render` and `run` take these two flags, with the same meaning they +have on [`mcp`](#mcp---attach-port---automation-read-root-dir---automation-write-root-dir): each is +repeatable, each folder must exist, and together they confine every file that subcommand touches. +Reads are allowed inside a read or write root, writes only inside a write root, and everything else +is refused — so a run given only a read root cannot write at all. Without either flag a subcommand +reads and writes as the user who started it. + +```sh +# Renders in/doc.figcraft to out/ok.png and can touch nothing else. +figcraft-cli render in/doc.figcraft -o out/ok.png \ + --automation-read-root in --automation-write-root out + +figcraft-cli render in/doc.figcraft -o ./escaped.png \ + --automation-read-root in --automation-write-root out +# error: automation path rejected: . is outside the folders that may be written: …/out +``` + +This is for driving the CLI from a script or an agent that should not reach the rest of the disk; +the flags leave the output identical when nothing strays outside the roots. + ### `commands [--json] [--filter TEXT]` Every command with its label, shortcut, undoable flag (`*`) and parameter docs; `--json` for @@ -164,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 2640ed1c..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-18 · **Change:** minor (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 @@ -16,7 +16,8 @@ accounts). | Per-layer overrides with reset; kept across main edits, nested swaps, copy/paste | done | main edits match a full rebuild exactly at 2,000 instances | | What an instance overrides: fills, strokes, effects, visibility, layer names, text content and every text property (resizing included), auto layout padding, gap and alignment, layout grids, export settings, prototype interactions, nested swaps and property values; the size and resizing (hug, fill, fixed, min and max) of layers inside it | done | each kept when the main changes that property, reset per group; opacity and corner radius too (documentation silent); size and resizing are kept through relayout, undo, save, `.fig` and REST import and detach, and stay with the instance on push | | What it doesn't: position, rotation, constraints and absolute or grid placement of layers inside it, its auto layout direction and other settings, its structure | done | refused with a message, the fields greyed out; the instance itself resizes and its layers follow the main's constraints | -| Component sets / variants, add variant, switch | done | | +| Component sets / variants, add variant, switch | done | Combine as variants is in the Object menu, and the canvas and layers context menu and the Design panel offer it when two or more main components are selected; Add variant on a main component not in a set yet makes it a set of itself and the new variant, one undo step (#903); components that are variants already aren't combined again (it used to put a set inside a set) | +| A main's copy within its file is an instance of it: Duplicate, Alt-drag, Copy then Paste | done | a variant duplicated in its set stays a new variant and a whole set copies as a new set; only the copied roots become instances (a main inside a copied frame is copied as a main); Cut then Paste and pastes into other files keep the component (#892) | | Switching variants keeps overrides where names match and both variants had the same value; text layers keep theirs | done | | | Default variant: the top-left one (new instances, Assets thumbnail, generated code) | done | | | Variant property order (names rewritten), duplicate combinations reported | done | drag a property in the Properties section to reorder it, one undo step (#679), or `component.editProperty {index}` | @@ -28,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 @@ -65,7 +66,7 @@ updates with overrides kept: `library_tests.rs`, `components_scale_tests.rs`. | Modes: add, duplicate, rename, delete, set as default / reorder | done | layers on Auto follow a new default; explicit modes stay | | Deleting a bound variable or its collection | done | layers keep their last value and are unbound; aliases to it take its values | | Code syntax per platform (Web, iOS, Android) | done | generated CSS uses the Web name; in the variables JSON; SwiftUI/Compose code doesn't name variables yet | -| Bindings: fills, strokes, effects, opacity, padding, gaps, sizes, radius, font size/weight, line height, letter spacing, paragraph spacing/indent, text, visibility, layout grids, instance properties | done | type-checked per field; number variables fill text content | +| Bindings: fills, strokes, effects, opacity, padding, gaps, sizes, radius, font size/weight, line height, letter spacing, paragraph spacing/indent, text, visibility, layout grids, instance properties | done | type-checked per field; number variables fill text content; a colour variable bound to a solid paint brings its whole colour, alpha included, once (the paint's opacity restarts at 100% on binding, an opacity set while bound multiplies on top; unbinding moves the alpha back into the opacity) | | Explicit modes per frame, library collections' modes | done | | | Gradient stops and variant properties bound to variables | done | a stop's binding follows it when earlier stops are removed | | Extended collections | done | library collections, including a library's own extended collections (chains through libraries); overrides per mode with reset; follow library updates, variables new to the library collection being an update of their own; modes inherited. Checked with synthetic libraries only ([gaps](gaps.md) #14) | @@ -76,6 +77,9 @@ 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 | | 2026-10-17 | minor | Variant properties reorder by dragging them in the Properties section, one undo step (#679; was "no drag in the panel yet"); a library file saved again right after it was read is read again, so a quick republish isn't missed (#652); the Hidden from publishing row no longer says the Hide when publishing item is missing (it landed 2026-10-12). Numbers unchanged | | 2026-10-17 | minor | Size and resizing overrides of layers inside instances (#731): a layer in an instance can hug, fill or keep a size of its own (group `resizing`: hug/fill/fixed with min and max; `size`), from the Layout panel, the canvas handles and `node.set`; they were refused, and imported ones were dropped at the first relayout. Kept through main edits of other properties, rebuilds, undo, save, `.fig` and REST import (sizes a `.fig` instance gives its layers and the REST `overrides` fields), `.fig` export and detach; reset per group and with Reset all changes (`instance_resizing_tests`). The row that listed them as not overridable was wrong. Numbers unchanged. | diff --git a/docs/control-protocol.md b/docs/control-protocol.md index 3ae9d287..3941bd8a 100644 --- a/docs/control-protocol.md +++ b/docs/control-protocol.md @@ -71,7 +71,10 @@ and what a person does in the window are held to the same folders. Files are rea read or write root and written only inside a write root; anything else is an ordinary error reply (`automation path rejected: …`), and recent files outside the roots aren't listed. Paths are resolved as `docs/mcp.md` › Confining file access describes. A root that isn't an existing folder -stops the app before its window opens. Without the flags nothing changes. +stops the app before its window opens, and so does any option the app doesn't know (a mistyped +flag such as `--automation-write-roots` exits with `unknown option`, never starts unconfined). +Without the flags nothing changes. The flags arrived in FigCraft 0.7.0: 0.6.0 and earlier ignore +them and confine nothing. Not confined: opening the files given on the command line when the app starts and the documents the system hands over (Finder, Open With; saving them later is confined like any write), and the @@ -128,7 +131,9 @@ component/instance links, overrides, `slot` (a slot layer's property name), boun `devStatus` (handoff status), `devChanged` (changed since it was marked ready for dev), `annotations` (Dev Mode notes: `[{label, properties, category?}]`), `codeLinks` (a component's links to code: `[{language, importPath, name, template}]`), `devResources` (links for developers, -its own and inherited: `[{name, url, index?, from?}]`). The auto layout props +its own and inherited: `[{name, url, index?, from?}]`), `pluginData` (layer data written with +`node.set {props: {pluginData: {key: value}}}`, pages included: `{key: value}`, values as strings; +only when there is some). The auto layout props that `node.set` accepts are listed in [mcp.md](mcp.md#conventions-agents-must-know). ### `ui.inspect {}` @@ -169,7 +174,10 @@ instance, a dragged axis slider; see `text.preview` in [mcp.md](mcp.md)). `canvas` or `properties`), whether `keyboard` mode is on (F6 or Tab moved the focus since the last pointer press, so the focus ring shows) and the name of the `control` with the keyboard (what a screen reader announces; empty when nothing has it). `reduceMotion` is true when the preference or -the system's accessibility setting turns the interface's animations off. `live` is the live +the system's accessibility setting turns the interface's animations off. `uiScale` is the interface +size: the `choice` (relative to the system's display scale, `pref.uiScale`), the `zoom` drawn +(relative too), what that is in physical pixels per point (`drawn`) and whether the window is too +small for the choice (`limited`, so `zoom` is smaller). `live` is the live session of the window's active file as the window shows it (sessions are per file: another tab shows its own, or `status: "off"`): `session` (its id, null when the file isn't in one), `sessions` (how many the app has running), `status`, `role`, the number of `peers`, who you `follow`, the @@ -248,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 @@ -285,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 b3a88be8..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 (Outline stroke preserves stroke holes; 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) | @@ -26,10 +26,10 @@ No alpha blockers: the alpha gate in [`roadmap.md`](roadmap.md#alpha-gate) passe | 10 | Team libraries: no team-wide lists or accounts; analytics are counts | Feature | 4–8 | [components-parity](components-parity.md) | | 11 | Spell check | Feature | 4–8 | [target-app-parity](target-app-parity.md) | | 12 | Video fills; HEIC images | File format | 5–10 | [file-format-parity](file-format-parity.md) | -| 13 | Vector tail: outline-stroke compositing (stroke rings preserved) | Feature | — | [target-app-parity](target-app-parity.md) | +| 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 @@ -307,14 +308,15 @@ No alpha blockers: the alpha gate in [`roadmap.md`](roadmap.md#alpha-gate) passe video need decoders that are C libraries (libheif, FFmpeg) or each platform's media framework. - **Estimate:** 5–10 h (video playback in the presenter is most of it; it can reuse the GIF clock). -### 13. Vector tail: outline-stroke compositing -- **Landed (2026-10-15):** Outline stroke keeps the renderer's even-odd stroke rings; regression pixels, Undo/Redo, native save/reopen and instance opacity inheritance/overrides in `outline_stroke`. -- **Missing:** preserving filled-shape opacity, blend and effects on the composite. A follow-up must preserve component inheritance and references when deciding which node owns these properties. +### 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 @@ -325,7 +327,7 @@ No alpha blockers: the alpha gate in [`roadmap.md`](roadmap.md#alpha-gate) passe Ratio; Mixed across ellipses; bindable), the shape builder on strokes and open paths (strokes take part as their outlined areas), one-sided variable widths (Alt-drag a width handle; canvas, PNG, SVG, PDF and outline stroke). -- **Estimate:** compositing not yet re-estimated; vector QA with real files remains part of gap 3. +- **Estimate:** none left (vector QA with real files is part of gap 3). ### 14. Variables: extended collections on real libraries - **Landed:** extended collections (`library.extendCollection`: a library collection's modes and @@ -373,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 @@ -442,13 +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 | M4: Outline stroke preserves even-odd stroke holes. Pixel comparisons cover alignment, dashes, individual side weights, vector paths and stroke-only opacity; native save/reopen, Undo/Redo and instance opacity inheritance/overrides are covered by `outline_stroke`. Existing estimates unchanged; filled-shape compositing needs separate work. | | 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 786fde0a..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 @@ -234,7 +240,12 @@ Or in a project's `.mcp.json`: `text.setVariations {variations, replace?}` merges instead. `text.fonts {family, style?, axes: true}` lists a font's axes and named instances (named instances are also valid `fontStyle` values). `text.missingFonts` lists the families the document names that aren't installed - (`{families: [{family, layers}]}`); their text draws in a fallback font. + (`{families: [{family, layers}]}`); their text draws in a fallback font. Fonts are indexed once + per process: after installing one, `text.rescanFonts` picks it up (`{added: [families]}`), and + `text.loadFont {path | data (base64)}` adds a font file without installing it (`{families}`). + Text boxes keep the size they were measured at: both refit the open document's auto-sizing text + in the new families (`refitted: [ids]`), and `text.refit {ids?}` does it for any text, e.g. a + file saved while its fonts were missing. OpenType features: `features {"liga": false, "tnum": true}`. Decorations: `textDecorationStyle` (`solid`, `dotted`, `wavy`), `textDecorationThickness` and `textDecorationOffset` (`auto`, px, or `"10%"` of the font size; the offset runs from the baseline @@ -262,12 +273,18 @@ 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. + layers using them (they keep their last value) and give aliases to them their values. An + explicit edit that changes a bound field's value (`node.set`, `transform.resize`, + `object.opacityKeys`, `text.style`, `text.set`, the text size, weight and + tracking shortcuts, a canvas drag) detaches that field's variable; fields the edit + left as they were stay bound. `variable.addMode {from}` duplicates a mode, `variable.moveMode {collection, mode, to}` reorders (index 0 is the default mode, which layers on Auto follow). `variable.update {codeSyntax: {WEB?, iOS?, ANDROID?}}` sets code names (`""` clears); generated CSS uses the Web one. @@ -290,7 +307,7 @@ Or in a project's `.mcp.json`: | `create_node {type, x, y, width, height, parent, index, name, text, path, props}` | Create a frame, rectangle, ellipse, polygon, star, line, arrow, text, vector, section, component or slice; returns `{id, node}`. | | `set_properties {ids, props}` | `node.set` on layers (geometry, fills, strokes, effects, corner radius, auto layout, sizing, constraints, masks (`isMask`, `maskType: alpha\|vector\|luminance`), handoff status (`devStatus: none\|readyForDev\|completed`; marking again clears "Changed"), a section's `sectionContentsHidden`, text…); returns the updated layers. | | `inspect_document {depth, allPages}` | The document as JSON (layer tree, styles, selection, history). | -| `get_node {id, depth}` | One layer as JSON. | +| `get_node {id, depth}` | One layer as JSON; `pluginData` (`{key: value}`, values as strings) when the layer or page has layer data written with `set_properties {props: {pluginData}}`. | | `select {ids}` | Replace the selection (`[]` deselects). | | `export {ids, format, scale \| size, path?, bleed?, cropMarks?, bleedMarks?, cmyk?, contentsOnly?, includeBoundingBox?, svgOutlineText?, svgIdAttribute?, svgSimplifyStroke?}` | png, jpg, svg, webp, pdf, or `fig`: the whole file as `.fig`, experimental (see Exporting `.fig` files below); `size` is `2x`, `512w` or `512h`. The image covers what the layers paint (shadows towards their offset, strokes, unclipped children, a mask group's mask; hidden layers take no room; a slice is its area with everything under it); `contentsOnly: false` also draws every other layer showing in that area, `includeBoundingBox` exports the layers' own boxes; SVG: `svgOutlineText: false` keeps ``, `svgIdAttribute` writes unique ids from layer names, `svgSimplifyStroke: false` writes every stroke as an outline. The same options go in export settings (`export.add` / `export.update`). PDF: frames one page each (sized in points), anything else together on one page; print options: `bleed` (points or a length like `"3mm"`, `"0.125in"`), crop and bleed marks, CMYK colour (see Exports below), also taken by `file.export`, `file.exportLayers`, `file.exportFramesPdf` and stored per export setting by `export.add` / `export.update`; `run_command` `file.exportFramesPdf {page?, path?}` writes every top-level frame of a page. Without `path`, images come back as MCP image content, SVG as text. Layers' own export settings: `run_command` with `export.add` / `export.update {index, size?, suffix?, format?}` / `export.remove` and `file.exportLayers {ids?, dir?, assets?}` (every layer once per setting, named `.`, a slash in a layer name making a folder; `assets: true` exports the Inspect tab's assets instead: icons as SVG, images as PNG). | | `screenshot {ids?, scale?, maxSize?, window?}` | PNG of the page (with background) or of layers, scaled to fit `maxSize` (default 1600). `window: true` captures the app window in front when attached, a window id (see Windows below) that window. | @@ -321,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 @@ -355,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. @@ -417,15 +485,28 @@ on; `key` resolves keys the way the canvas does. Commands behind keys: nested layers), Escape or a click drops it. - `pref.reduceMotion {on?}`: reduced motion (the interface's own animations off; the system's accessibility setting turns it on too). Prototype transitions still play. +- `pref.uiScale {scale?}`: how large the whole interface is drawn, relative to the system's display + scale: 1 (the default) is the system's own size on every screen, 1.25 means 125 %, not 125; from + 0.75 to 3 (outside the range it stops at the nearest end; `"auto"` is 1). Omit `scale` to read it. + The canvas zoom is separate. A window too small for a size above 100 % draws a smaller one until + it is larger; the choice is kept. In the browser the interface always follows the browser's zoom. - `pref.autoSave {on?}`: files that have a path save themselves a few seconds after they change (off by default; the desktop app's timer does it, so scripts still call `file.save`). -- `pref.language {language: system|en|cs|ru|es|es-419|fr|de|pt-BR|ja|ko|zh-Hans|…}`: the app's interface language, applied live ("system": -- `pref.language {language: system|en|es|es-419|fr|de|pt-BR|ja|ko|sv|zh-Hans|…}`: the app's interface language, applied live ("system": +- `pref.startOnHome {on?}`: the desktop app starts on the Home screen instead of in a new file + (off by default; files named on the command line still open, and `--control` starts in a new + file anyway, so control callers have a document). +- `pref.language {language: system|en|cs|ru|es|es-419|fr|de|pt-BR|ja|ka|ko|sv|zh-Hans|…}`: the app's interface language, applied live ("system": the OS or browser language; a language without a catalog shows English). Command ids, labels and every result stay English. ### Editing vectors +`vector.create {path | points}` makes the layer's box from the geometry; `width`/`height` scale +the geometry to the size asked for (with both, each side independently; with one, the other keeps +its proportion). A path or point set that draws nothing is refused instead of making an invisible +0×0 layer, as is a size on an axis the geometry has none of (except 0, such as a horizontal line's +height) and a size of 0 or less on an axis it has, which would flatten it. + Vector edit mode is driven with `run_command` (or `pointer`/`key` for gestures): `vector.edit.begin {id? | ids?}` (default the selection; converts shapes to vectors; several layers are edited at once, the first active; `key` Enter with shapes selected does the same), @@ -562,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 @@ -698,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 @@ -972,10 +1056,11 @@ branches). Driven with `run_command`: name, property, branch, main, deletedBy?}]}`. `layers` are the changed layers with their property changes formatted like Inspect (`fill #2563EB → #DC2626`); `document` lists styles, variables, comments and file settings. A conflict is the same property changed on both sides to different - values (key `/`, e.g. `4/fills`; fills, strokes and effects compare item by - item, so changing different items of one list on each side, or adding items at its end on one - side, merges without a conflict; a layer's parent and position are one - property, `parent`), a layer deleted on one side while the other edited it or what's inside it + values (key `/`, e.g. `4/fills`; fills, strokes, effects, layout grids, + export settings, guides, prototype interactions, annotations and Dev Mode resources compare item + by item, so changing different items of one list on each side, or adding items at its end on one + side, merges without a conflict; other lists, such as code links, compare whole; a layer's parent + and position are one property, `parent`), a layer deleted on one side while the other edited it or what's inside it (`/layer`, with `deletedBy`), or a document-level entry or setting changed on both sides (`style/`, `variable/`, `comment/`, `doc/`…). - `branch.merge {id?, resolutions?}` merges the branch into the main file as one undo step, @@ -1279,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 `