From b9aa935d193b353f9349ba6fb469b7036211f30f Mon Sep 17 00:00:00 2001 From: Ivan Date: Sun, 11 Oct 2026 01:41:45 +0300 Subject: [PATCH 01/47] CI: run tests with nextest and skip building examples (#2204) cargo test runs the ~200 test binaries one after another; on Windows that took 14 of the job's 37 minutes, 5 of them in gpu's parity tests on the software adapter. nextest runs them in parallel with the GPU group from .config/nextest.toml. The examples were built and linked but never run; Clippy's --all-targets still checks them. Doctests get their own step. Co-Authored-By: Claude Opus 5.5 --- .github/workflows/ci.yml | 23 ++++++++++++++++++----- 1 file changed, 18 insertions(+), 5 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index bec29abaf5..5f306edae4 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -115,9 +115,21 @@ jobs: if: runner.os == 'Linux' run: cargo fmt --all -- --check + # nextest runs the test binaries in parallel (cargo test runs them one at a time), with the + # GPU tests capped by the `gpu` group in .config/nextest.toml (#2204). + - uses: taiki-e/install-action@9983c65e42da123ff25d1f78505eb6de315aa172 # v2.87.20 + with: + tool: cargo-nextest@0.9.146 + # `--features heif`: official builds open HEIC photos via the optional photocraft-heif crate. + # `--lib --bins --tests` leaves out the examples, which cargo test would build and link but + # never run (Clippy's `--all-targets` still checks they compile). nextest skips doctests, so + # they get their own step. - name: Test - run: cargo test --workspace --locked --features heif + run: cargo nextest run --workspace --locked --features heif --lib --bins --tests + + - name: Doctests + run: cargo test --workspace --locked --features heif --doc # The same codecs without the optional decoder: HEIC must stay a clear "not in this build" # error for distributors who leave it out. @@ -127,9 +139,10 @@ jobs: # The same tests built with the optional craft-fonts input (crates/text/build.rs), so the # Japanese font tests that skip without it run here. Linux only. Keep the exact `--workspace - # --features heif` of the Test step: any other package or feature selection resolves a - # different feature set, recompiles the whole dependency graph into the same target dir and - # runs the runner out of disk (as `-p photocraft-text -p photocraft-ui-egui` did after #800). + # --features heif --lib --bins --tests` of the Test step: any other package, target or + # feature selection resolves a different feature set, recompiles the whole dependency graph + # into the same target dir and runs the runner out of disk (as `-p photocraft-text -p + # photocraft-ui-egui` did after #800). - name: Check out craft-fonts if: runner.os == 'Linux' uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4 @@ -143,7 +156,7 @@ jobs: env: CRAFT_FONTS_DIR: ${{ github.workspace }}/craft-fonts CRAFT_FONTS_REQUIRED: "1" - run: cargo test --workspace --locked --features heif + run: cargo nextest run --workspace --locked --features heif --lib --bins --tests - name: Clippy run: cargo clippy --workspace --all-targets --locked --features heif -- -D warnings From bf93459200f5d79e6212ce7f6a24c4bab4e5c96d Mon Sep 17 00:00:00 2001 From: Ivan Date: Sun, 11 Oct 2026 05:06:06 +0300 Subject: [PATCH 02/47] CI: no debuginfo for the Windows test build Writing PDBs for ~200 test binaries took 2.2 of ~21 build minutes on the Windows runner (3 runs each on the same CPU model). A panic still reports its file and line; Linux keeps line-tables-only for backtraces with lines. Co-Authored-By: Claude Opus 5.5 --- .github/workflows/ci.yml | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 5f306edae4..05e62ee140 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -84,6 +84,11 @@ jobs: fail-fast: false matrix: os: ${{ fromJSON(needs.platforms.outputs.os) }} + env: + # No debuginfo on Windows: writing PDBs for ~200 test binaries cost 2.2 of ~21 build minutes. + # A panic still names its file and line; only backtrace frames lose theirs (Linux keeps + # line-tables-only, so a backtrace with lines is one OS away). + CARGO_PROFILE_DEV_DEBUG: ${{ startsWith(matrix.os, 'windows') && '0' || 'line-tables-only' }} steps: - uses: actions/checkout@v4 From 7b1dd35b7bb3cd8c180971040a1eadc1ab0000d4 Mon Sep 17 00:00:00 2001 From: Ivan Date: Sun, 11 Oct 2026 05:07:17 +0300 Subject: [PATCH 03/47] CI: report every failing test, not just the first With the binaries running in parallel, running the rest after a failure costs little, and a red main no longer hides other failures behind the first one. Co-Authored-By: Claude Opus 5.5 --- .github/workflows/ci.yml | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 05e62ee140..8a0ec7391c 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -131,7 +131,7 @@ jobs: # never run (Clippy's `--all-targets` still checks they compile). nextest skips doctests, so # they get their own step. - name: Test - run: cargo nextest run --workspace --locked --features heif --lib --bins --tests + run: cargo nextest run --workspace --locked --features heif --lib --bins --tests --no-fail-fast - name: Doctests run: cargo test --workspace --locked --features heif --doc @@ -161,7 +161,7 @@ jobs: env: CRAFT_FONTS_DIR: ${{ github.workspace }}/craft-fonts CRAFT_FONTS_REQUIRED: "1" - run: cargo nextest run --workspace --locked --features heif --lib --bins --tests + run: cargo nextest run --workspace --locked --features heif --lib --bins --tests --no-fail-fast - name: Clippy run: cargo clippy --workspace --all-targets --locked --features heif -- -D warnings From fa17b6f4b191fdcaafb3cfceb530e4283d09a90b Mon Sep 17 00:00:00 2001 From: Ivan Date: Sun, 11 Oct 2026 05:08:46 +0300 Subject: [PATCH 04/47] CI: run the Linux without-heif and craft-fonts builds in parallel They ran after the main tests in the Linux test job and added ~10 min to its critical path (and were why that job ran out of disk). Now a two-leg job restores the test job's cache read-only (shared-key: test) and saves nothing, so the cache budget (#2688) doesn't grow. Co-Authored-By: Claude Opus 5.5 --- .github/workflows/ci.yml | 86 +++++++++++++++++++++++++++------------- 1 file changed, 59 insertions(+), 27 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 8a0ec7391c..dcb0017775 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -136,33 +136,6 @@ jobs: - name: Doctests run: cargo test --workspace --locked --features heif --doc - # The same codecs without the optional decoder: HEIC must stay a clear "not in this build" - # error for distributors who leave it out. - - name: Test without heif - if: runner.os == 'Linux' - run: cargo test -p photocraft-heif -p photocraft-codecs -p photocraft-io --locked - - # The same tests built with the optional craft-fonts input (crates/text/build.rs), so the - # Japanese font tests that skip without it run here. Linux only. Keep the exact `--workspace - # --features heif --lib --bins --tests` of the Test step: any other package, target or - # feature selection resolves a different feature set, recompiles the whole dependency graph - # into the same target dir and runs the runner out of disk (as `-p photocraft-text -p - # photocraft-ui-egui` did after #800). - - name: Check out craft-fonts - if: runner.os == 'Linux' - uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4 - with: - repository: storytold/craft-fonts - ref: abb83316d96aa59c1cf64784289e378fe9fa5695 - path: craft-fonts - - - name: Test with craft-fonts - if: runner.os == 'Linux' - env: - CRAFT_FONTS_DIR: ${{ github.workspace }}/craft-fonts - CRAFT_FONTS_REQUIRED: "1" - run: cargo nextest run --workspace --locked --features heif --lib --bins --tests --no-fail-fast - - name: Clippy run: cargo clippy --workspace --all-targets --locked --features heif -- -D warnings @@ -175,6 +148,65 @@ jobs: if: runner.os == 'Linux' run: cargo xtask scorecard --check + # The two other builds of the Linux tests, in parallel with the test job instead of after it (they + # added ~10 min to its critical path). Each restores the test job's cache (`shared-key: test` is + # that job's key) and saves none: the dependencies are the same, and the 10 GB cache budget is + # tight (#2688). + test-variants: + name: test (${{ matrix.variant }}) + runs-on: ubuntu-latest + strategy: + fail-fast: false + matrix: + variant: [without heif, with craft-fonts] + steps: + - uses: actions/checkout@v4 + + - name: Install Linux system dependencies (egui/winit/rfd) + run: | + nohup sudo rm -rf /usr/local/lib/android /usr/share/dotnet /opt/ghc /usr/local/.ghcup \ + /opt/hostedtoolcache/CodeQL /dev/null 2>&1 & + 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 + + - uses: Swatinem/rust-cache@v2 + with: + shared-key: test + save-if: false + + - uses: taiki-e/install-action@9983c65e42da123ff25d1f78505eb6de315aa172 # v2.87.20 + if: matrix.variant == 'with craft-fonts' + with: + tool: cargo-nextest@0.9.146 + + # The same codecs without the optional decoder: HEIC must stay a clear "not in this build" + # error for distributors who leave it out. + - name: Test without heif + if: matrix.variant == 'without heif' + run: cargo test -p photocraft-heif -p photocraft-codecs -p photocraft-io --locked + + # The tests built with the optional craft-fonts input (crates/text/build.rs), so the Japanese + # font tests that skip without it run. Keep the test job's exact `--workspace --features heif + # --lib --bins --tests`, so the restored dependencies match and only the workspace rebuilds. + - name: Check out craft-fonts + if: matrix.variant == 'with craft-fonts' + uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4 + with: + repository: storytold/craft-fonts + ref: abb83316d96aa59c1cf64784289e378fe9fa5695 + path: craft-fonts + + - name: Test with craft-fonts + if: matrix.variant == 'with craft-fonts' + env: + CRAFT_FONTS_DIR: ${{ github.workspace }}/craft-fonts + CRAFT_FONTS_REQUIRED: "1" + run: cargo nextest run --workspace --locked --features heif --lib --bins --tests --no-fail-fast + corpus: # Real-file corpus tests (cargo feature `corpus`), on every PR and on main. The corpora are # pinned in xtask/src/corpus_pins.rs and sha256-verified (xtask/*.sha256) after both a cache From 2ff756b51a572f5339aa48e0314c24490b74204d Mon Sep 17 00:00:00 2001 From: Ivan Date: Sun, 11 Oct 2026 06:36:32 +0300 Subject: [PATCH 05/47] Tests: build ui-egui without optimization in the test profile Its library and unit tests were the biggest test-build units after engine's (12.6 of ~63 compile minutes on the Windows CI runner), and its tests run no heavy pixel code of their own. On the Windows runner (EPYC 7763, 3 runs vs 7): build 18.9 -> 15.4 min, test run 10.0 -> 10.5. Only test builds change; cargo run and cargo build use the dev profile. Co-Authored-By: Claude Opus 5.5 --- Cargo.toml | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/Cargo.toml b/Cargo.toml index 2c329d980d..f945869aa3 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -78,6 +78,14 @@ opt-level = 1 # CARGO_PROFILE_DEV_DEBUG. To inspect locals in a debugger, build with CARGO_PROFILE_DEV_DEBUG=true. debug = "line-tables-only" +# Test builds compile ui-egui without optimization: its library and unit tests were the biggest +# test-build units after engine's (12.6 of ~63 compile minutes on the Windows CI runner), and its +# tests run no heavier pixel code of their own (that lives in the crates that stay optimized). +# Measured on CI: Windows build 18.9 -> 15.4 min, test run 10.0 -> 10.5. `cargo run` and +# `cargo build` use the dev profile and are unaffected. +[profile.test.package.photocraft-ui-egui] +opt-level = 0 + [profile.release] lto = "thin" codegen-units = 1 From 6382148eb846f5c4e8c588f5aea7fb4354ee5249 Mon Sep 17 00:00:00 2001 From: Ivan Date: Sun, 11 Oct 2026 07:48:54 +0300 Subject: [PATCH 06/47] CI: run the Windows tests as two parallel legs Unit tests (--lib --bins, plus doctests) and integration tests (--test '*', plus Clippy and the layering check) now run on two Windows runners at once. The feature selection is the same, so both legs restore one dependency cache; the integration leg saves it, so Clippy's metadata stays in it. On the Windows runner (EPYC 7763) the job took 28.3 min; the legs take 18.2 and 18.3, running the same 6095 tests between them. Co-Authored-By: Claude Opus 5.5 --- .github/workflows/ci.yml | 39 ++++++++++++++++++++++++++++++--------- 1 file changed, 30 insertions(+), 9 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index dcb0017775..d3e7176afc 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -42,7 +42,7 @@ jobs: name: select platforms runs-on: ubuntu-latest outputs: - os: ${{ steps.pick.outputs.os }} + legs: ${{ steps.pick.outputs.legs }} web: ${{ steps.pick.outputs.web }} steps: - uses: actions/checkout@v4 @@ -56,9 +56,12 @@ jobs: run: | # No macOS on PRs or main: the org's 5 macOS slots are kept for release builds, and # release.yml builds and tests macOS (a failing release is fixed and rebuilt). - all='["ubuntu-latest","windows-latest"]' + # Windows tests run as two legs in parallel (unit tests, integration tests): together they + # took ~28 min on the Windows runner, each leg takes ~18. + linux='{"os":"ubuntu-latest","part":"all"}' + all="[$linux,{\"os\":\"windows-latest\",\"part\":\"unit\"},{\"os\":\"windows-latest\",\"part\":\"integration\"}]" if [ "$EVENT" != pull_request ] || [ "$FULL" = true ]; then - echo "os=$all" >> "$GITHUB_OUTPUT" + echo "legs=$all" >> "$GITHUB_OUTPUT" echo "web=true" >> "$GITHUB_OUTPUT" exit 0 fi @@ -71,19 +74,19 @@ jobs: echo "web=false" >> "$GITHUB_OUTPUT" fi if git diff --name-only "$BASE" HEAD | grep -Eq '^(apps/|crates/(ui-egui|text|gpu|tablet|heif)/|Cargo\.lock$|(.*/)?build\.rs$|\.github/workflows/ci\.yml$)'; then - echo "os=$all" >> "$GITHUB_OUTPUT" + echo "legs=$all" >> "$GITHUB_OUTPUT" else - echo 'os=["ubuntu-latest"]' >> "$GITHUB_OUTPUT" + echo "legs=[$linux]" >> "$GITHUB_OUTPUT" fi test: - name: test (${{ matrix.os }}) + name: test (${{ matrix.os }}${{ matrix.part != 'all' && format(', {0}', matrix.part) || '' }}) needs: platforms runs-on: ${{ matrix.os }} strategy: fail-fast: false matrix: - os: ${{ fromJSON(needs.platforms.outputs.os) }} + include: ${{ fromJSON(needs.platforms.outputs.legs) }} env: # No debuginfo on Windows: writing PDBs for ~200 test binaries cost 2.2 of ~21 build minutes. # A panic still names its file and line; only backtrace frames lose theirs (Linux keeps @@ -111,9 +114,11 @@ jobs: # Only main saves caches; PRs restore main's. Every PR used to save its own set (up to # 3.8 GB), usable by that PR only, so the repo's 10 GB cache budget kept evicting main's. # A failing test still saves: the cache holds compiled dependencies, not test results. + # The two Windows legs share one key (same dependencies); the integration leg saves it, + # because it also runs Clippy and so leaves Clippy's dependency metadata in it. - uses: Swatinem/rust-cache@v2 with: - save-if: ${{ github.ref == 'refs/heads/main' }} + save-if: ${{ github.ref == 'refs/heads/main' && matrix.part != 'unit' }} cache-on-failure: true - name: Format @@ -130,16 +135,32 @@ jobs: # `--lib --bins --tests` leaves out the examples, which cargo test would build and link but # never run (Clippy's `--all-targets` still checks they compile). nextest skips doctests, so # they get their own step. + # + # On Windows the unit leg runs `--lib --bins` and the integration leg `--test '*'` (every + # integration-test target); the feature selection stays the same, so both legs share the + # dependency cache. - name: Test - run: cargo nextest run --workspace --locked --features heif --lib --bins --tests --no-fail-fast + env: + PART: ${{ matrix.part }} + run: | + case "$PART" in + unit) targets=(--lib --bins) ;; + integration) targets=(--test '*') ;; + *) targets=(--lib --bins --tests) ;; + esac + cargo nextest run --workspace --locked --features heif "${targets[@]}" --no-fail-fast + shell: bash - name: Doctests + if: matrix.part != 'integration' run: cargo test --workspace --locked --features heif --doc - name: Clippy + if: matrix.part != 'unit' run: cargo clippy --workspace --all-targets --locked --features heif -- -D warnings - name: Dependency layering + if: matrix.part != 'unit' run: cargo xtask layers # docs/scorecard.md must match its sources (checklists, budgets, baseline, corpus floors, From 60d682d65d3978b09c00750502ea52d9a71900b3 Mon Sep 17 00:00:00 2001 From: Ivan Date: Sun, 11 Oct 2026 08:58:53 +0300 Subject: [PATCH 07/47] CI: cache the Windows workspace libraries with sccache rust-cache keeps only the dependencies, so every run recompiled the workspace crates. sccache caches their libraries (test executables can't be cached and still link). Warm, on the Windows runner (EPYC 7763, 2-3 runs), the unit leg built in 7.1 min instead of 11.5 and the integration leg in 4.3 instead of 8.8. Only main writes (PRs use SCCACHE_GHA_RW_MODE=READ_ONLY); a full set of entries was ~90 MB. Co-Authored-By: Claude Opus 5.5 --- .github/workflows/ci.yml | 20 ++++++++++++++++++++ 1 file changed, 20 insertions(+) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index d3e7176afc..fa3299cca0 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -121,6 +121,26 @@ jobs: save-if: ${{ github.ref == 'refs/heads/main' && matrix.part != 'unit' }} cache-on-failure: true + # sccache caches the workspace crates' compiled libraries, which rust-cache leaves out: + # warm, each Windows leg built 4.5 min faster. Like rust-cache, only main writes (PRs read + # main's entries); a full set is ~90 MB, so it barely touches the 10 GB budget (#2688). + # Test executables can't be cached, so a PR still links its tests. + - name: Install sccache (Windows) + if: runner.os == 'Windows' + uses: Mozilla-Actions/sccache-action@fc920bf0ec8de6ee65d409111f7ec508035751ba # v0.0.11 + with: + version: v0.18.0 + + - name: Use sccache (Windows) + if: runner.os == 'Windows' + env: + MAIN: ${{ github.ref == 'refs/heads/main' }} + run: | + echo "SCCACHE_GHA_ENABLED=true" >> "$GITHUB_ENV" + echo "RUSTC_WRAPPER=sccache" >> "$GITHUB_ENV" + if [ "$MAIN" != true ]; then echo "SCCACHE_GHA_RW_MODE=READ_ONLY" >> "$GITHUB_ENV"; fi + shell: bash + - name: Format if: runner.os == 'Linux' run: cargo fmt --all -- --check From 110d58bea91c87bf1d7a3ea9b9d05bebe5d1d80a Mon Sep 17 00:00:00 2001 From: Ivan Date: Sun, 11 Oct 2026 10:06:39 +0300 Subject: [PATCH 08/47] CI: split the Windows integration tests over two runners; retry flaky GPU tests nextest --partition splits the integration tests in two, so Windows runs three legs: on the Windows runner (EPYC 7763, 2 runs) the integration halves took 9.8 and 10.8 min against 13.8 for one, leaving the unit leg (~12.8) as the longest. A ci nextest profile retries the GPU tests: on WARP, under load, one hit wgpu's poll timeout once in ~40 runs while measuring. A test that passes on retry is reported as FLAKY. Co-Authored-By: Claude Opus 5.5 --- .config/nextest.toml | 7 +++++++ .github/workflows/ci.yml | 32 ++++++++++++++++++-------------- 2 files changed, 25 insertions(+), 14 deletions(-) diff --git a/.config/nextest.toml b/.config/nextest.toml index 2585577ec1..bb2a3881b2 100644 --- a/.config/nextest.toml +++ b/.config/nextest.toml @@ -13,3 +13,10 @@ gpu = { max-threads = 4 } [[profile.default.overrides]] filter = 'package(photocraft-gpu) | (package(photocraft-ui-egui) & (kind(test) | test(/canvas|gpu|mip|type_tool/)))' test-group = 'gpu' + +# CI (`--profile ci`): the GPU tests run on WARP, the software adapter, on the Windows runner, and +# under load one occasionally hits that same timeout (1 in ~40 runs while measuring #2933). Retry +# them; nextest reports a test that passes on retry as FLAKY, so it stays visible. +[[profile.ci.overrides]] +filter = 'package(photocraft-gpu) | (package(photocraft-ui-egui) & (kind(test) | test(/canvas|gpu|mip|type_tool/)))' +retries = 2 diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index fa3299cca0..337f75ef60 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -56,10 +56,12 @@ jobs: run: | # No macOS on PRs or main: the org's 5 macOS slots are kept for release builds, and # release.yml builds and tests macOS (a failing release is fixed and rebuilt). - # Windows tests run as two legs in parallel (unit tests, integration tests): together they - # took ~28 min on the Windows runner, each leg takes ~18. + # Windows tests run as three legs in parallel: the unit tests, and the integration tests + # split in two (nextest --partition). As one job they took ~28 min on the Windows runner; + # the legs take ~13, ~10 and ~11. linux='{"os":"ubuntu-latest","part":"all"}' - all="[$linux,{\"os\":\"windows-latest\",\"part\":\"unit\"},{\"os\":\"windows-latest\",\"part\":\"integration\"}]" + win() { printf '{"os":"windows-latest","part":"%s"}' "$1"; } + all="[$linux,$(win unit),$(win integration-1),$(win integration-2)]" if [ "$EVENT" != pull_request ] || [ "$FULL" = true ]; then echo "legs=$all" >> "$GITHUB_OUTPUT" echo "web=true" >> "$GITHUB_OUTPUT" @@ -114,11 +116,11 @@ jobs: # Only main saves caches; PRs restore main's. Every PR used to save its own set (up to # 3.8 GB), usable by that PR only, so the repo's 10 GB cache budget kept evicting main's. # A failing test still saves: the cache holds compiled dependencies, not test results. - # The two Windows legs share one key (same dependencies); the integration leg saves it, + # The Windows legs share one key (same dependencies); the integration-2 leg saves it, # because it also runs Clippy and so leaves Clippy's dependency metadata in it. - uses: Swatinem/rust-cache@v2 with: - save-if: ${{ github.ref == 'refs/heads/main' && matrix.part != 'unit' }} + save-if: ${{ github.ref == 'refs/heads/main' && (matrix.part == 'all' || matrix.part == 'integration-2') }} cache-on-failure: true # sccache caches the workspace crates' compiled libraries, which rust-cache leaves out: @@ -156,31 +158,33 @@ jobs: # never run (Clippy's `--all-targets` still checks they compile). nextest skips doctests, so # they get their own step. # - # On Windows the unit leg runs `--lib --bins` and the integration leg `--test '*'` (every - # integration-test target); the feature selection stays the same, so both legs share the - # dependency cache. + # On Windows the unit leg runs `--lib --bins` and the integration legs `--test '*'` (every + # integration-test target), half of the tests each; the feature selection stays the same, so + # all legs share the dependency cache. The `ci` profile retries the GPU tests + # (.config/nextest.toml). - name: Test env: PART: ${{ matrix.part }} run: | case "$PART" in unit) targets=(--lib --bins) ;; - integration) targets=(--test '*') ;; + integration-1) targets=(--test '*' --partition count:1/2) ;; + integration-2) targets=(--test '*' --partition count:2/2) ;; *) targets=(--lib --bins --tests) ;; esac - cargo nextest run --workspace --locked --features heif "${targets[@]}" --no-fail-fast + cargo nextest run --profile ci --workspace --locked --features heif "${targets[@]}" --no-fail-fast shell: bash - name: Doctests - if: matrix.part != 'integration' + if: matrix.part == 'all' || matrix.part == 'unit' run: cargo test --workspace --locked --features heif --doc - name: Clippy - if: matrix.part != 'unit' + if: matrix.part == 'all' || matrix.part == 'integration-2' run: cargo clippy --workspace --all-targets --locked --features heif -- -D warnings - name: Dependency layering - if: matrix.part != 'unit' + if: matrix.part == 'all' || matrix.part == 'integration-2' run: cargo xtask layers # docs/scorecard.md must match its sources (checklists, budgets, baseline, corpus floors, @@ -246,7 +250,7 @@ jobs: env: CRAFT_FONTS_DIR: ${{ github.workspace }}/craft-fonts CRAFT_FONTS_REQUIRED: "1" - run: cargo nextest run --workspace --locked --features heif --lib --bins --tests --no-fail-fast + run: cargo nextest run --profile ci --workspace --locked --features heif --lib --bins --tests --no-fail-fast corpus: # Real-file corpus tests (cargo feature `corpus`), on every PR and on main. The corpora are From 39904a27ce2f9c4ec122020f7f59d4cb26c1e4b3 Mon Sep 17 00:00:00 2001 From: Ivan Date: Sun, 11 Oct 2026 11:32:35 +0300 Subject: [PATCH 09/47] Docs: CI runs nextest, the craft-fonts tests have their own job Comments and docs still said CI runs cargo test, that the craft-fonts tests run a second time in the Linux job, and that CI's debuginfo matches the dev profile everywhere. development.md also notes that test builds compile ui-egui unoptimized. Co-Authored-By: Claude Opus 5.5 --- .config/nextest.toml | 3 ++- .github/workflows/ci.yml | 4 ++-- Cargo.toml | 3 ++- docs/development.md | 6 +++--- 4 files changed, 9 insertions(+), 7 deletions(-) diff --git a/.config/nextest.toml b/.config/nextest.toml index bb2a3881b2..31d597451a 100644 --- a/.config/nextest.toml +++ b/.config/nextest.toml @@ -1,5 +1,6 @@ # cargo-nextest (https://nexte.st): runs every test in its own process, in parallel across test -# binaries. Optional for local runs; `cargo test` keeps working and stays what CI runs (#2204). +# binaries. CI runs it (`--profile ci`, below); locally it's optional and `cargo test` keeps working +# (#2204). # # cargo nextest run --workspace # all tests except doctests # cargo test --doc --workspace # nextest does not run doctests diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 337f75ef60..dfe21e1498 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -22,8 +22,8 @@ env: PHOTOCRAFT_NO_PRINT: "1" LPDEST: photocraft-no-such-printer # Debug and test builds keep line tables (file:line in panic backtraces) but drop the rest of the - # debug info: faster codegen and links (Windows PDBs most of all), and a target dir small enough - # for the Linux job's second, craft-fonts build (it ran out of disk with full debug info). + # debug info: faster codegen and links, and a smaller target dir (the Linux test job ran out of + # disk with full debug info). The Windows test legs drop debuginfo entirely (see the test job). CARGO_PROFILE_DEV_DEBUG: line-tables-only # A newer push to a PR branch cancels its older run. Runs on main are never cancelled: a merge diff --git a/Cargo.toml b/Cargo.toml index f945869aa3..e97b3cd474 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -75,7 +75,8 @@ opt-level = 2 opt-level = 1 # Line tables keep file:line in panic backtraces but drop variable info, which roughly halves link # time and PDB size for the ~260 test binaries (#2204). CI sets the same through -# CARGO_PROFILE_DEV_DEBUG. To inspect locals in a debugger, build with CARGO_PROFILE_DEV_DEBUG=true. +# CARGO_PROFILE_DEV_DEBUG (and no debuginfo at all for the Windows tests). To inspect locals in a +# debugger, build with CARGO_PROFILE_DEV_DEBUG=true. debug = "line-tables-only" # Test builds compile ui-egui without optimization: its library and unit tests were the biggest diff --git a/docs/development.md b/docs/development.md index 3c539f70bc..cbe9130304 100644 --- a/docs/development.md +++ b/docs/development.md @@ -44,7 +44,7 @@ cargo xtask stats # tests and lines per cargo xtask parity # Photoshop menu coverage -> docs/parity-checklist.md ``` -Image code is slow at `opt-level 0`, so the workspace profile builds dependencies at `opt-level 2`. Use `--release` for anything interactive. +Image code is slow at `opt-level 0`, so the workspace profile builds dependencies at `opt-level 2`. Use `--release` for anything interactive. Test builds compile ui-egui itself at `opt-level 0` (`[profile.test.package.photocraft-ui-egui]` in `Cargo.toml`): it was one of the biggest test-build units, and its tests run no heavy pixel code of their own. The dev profile builds with `debug = "line-tables-only"`: panic backtraces keep file and line, and test binaries link about twice as fast. To inspect local variables in a debugger, build with `CARGO_PROFILE_DEV_DEBUG=true`. @@ -59,7 +59,7 @@ cargo nextest run --workspace # parallel across binaries (cargo ins cargo test --doc --workspace # nextest does not run doctests ``` -[cargo-nextest](https://nexte.st) runs each test in its own process. `.config/nextest.toml` puts the GPU tests (`photocraft-gpu`, and ui-egui's integration and canvas/GPU tests) in a group of at most 4 at a time, because each process opens its own wgpu device and the in-process `gpu_lock()` no longer serializes them. On a 32-thread Windows machine the full workspace went from 297 s (`cargo test`) to about 120 s (#2204). Ignored tests run with `cargo nextest run --run-ignored only`. CI still runs `cargo test`, and a full `cargo test` stays the final check before a PR. +[cargo-nextest](https://nexte.st) runs each test in its own process. `.config/nextest.toml` puts the GPU tests (`photocraft-gpu`, and ui-egui's integration and canvas/GPU tests) in a group of at most 4 at a time, because each process opens its own wgpu device and the in-process `gpu_lock()` no longer serializes them. On a 32-thread Windows machine the full workspace went from 297 s (`cargo test`) to about 120 s (#2204). Ignored tests run with `cargo nextest run --run-ignored only`. CI runs nextest too (`--profile ci`, which retries the GPU tests), and a full `cargo test` stays the final check before a PR. ## Fonts (craft-fonts) @@ -78,7 +78,7 @@ CRAFT_FONTS_DIR="$PWD/../craft-fonts" cargo test --workspace # runs the Japa - **UI:** the Japanese fonts (BIZ UDPGothic Regular first) are the first Japanese fallback in the lazy CJK loader (`crates/ui-egui/src/cjk_fonts.rs`), ahead of the system Japanese fonts and in the same locale script order, appended last to every egui family with the usual baseline alignment. - **Type tool:** the text engine registers them in `FontDb::new` (so also with no system fonts) and puts them first in the Japanese slot of the locale-ordered fallback list: BIZ UDPGothic for sans runs, Shippori Mincho / BIZ UDMincho for serif runs. - **Web:** the wasm32 build never embeds craft-fonts, even with `CRAFT_FONTS_DIR` set: measured on 2026-10-06, the UI face alone took the release wasm from 24.19 MB to 28.87 MB, over the 24 MiB gate in `packaging/web/package.sh`. Instead it loads fonts served next to it, on demand: `fonts/manifest.txt` beside `index.html`, in craft-fonts' manifest format (`crates/text/src/served.rs`; hosting: `packaging/web/README.md` › Fonts). `PHOTOCRAFT_WEB_FONTS_DIR="$PWD/../craft-fonts" packaging/web/package.sh` puts craft-fonts there. Served families are in the font menus and are fetched when picked or when a document's text needs them; they aren't in the script fallback list yet, so Japanese text set in a Latin font still needs a Japanese font picked (#1615). -- Tests that need the fonts skip with a message when `CRAFT_FONTS` is empty; CI's Linux job runs the tests a second time with `CRAFT_FONTS_DIR` set. Desktop releases check out craft-fonts at the commit pinned in `.github/workflows/release.yml` (`CRAFT_FONTS_REF`; ci.yml pins the same commit) and ship each font's `OFL.txt` as `OFL-.txt`. +- Tests that need the fonts skip with a message when `CRAFT_FONTS` is empty; CI runs them again with `CRAFT_FONTS_DIR` set, in its own Linux job (`test (with craft-fonts)`). Desktop releases check out craft-fonts at the commit pinned in `.github/workflows/release.yml` (`CRAFT_FONTS_REF`; ci.yml pins the same commit) and ship each font's `OFL.txt` as `OFL-.txt`. ## Graphics startup and device loss From e5bf9c22c92acdc02d49cfda06df1aa77c698b8e Mon Sep 17 00:00:00 2001 From: Ivan Date: Wed, 14 Oct 2026 01:48:31 +0300 Subject: [PATCH 10/47] CI: run the Windows tests on two runners instead of three The integration tests run as one leg again. On the Windows runner (EPYC 7763, 3 runs) that leg took 14.3-15.3 min, against 10.5-10.8 for each half, so the slowest leg is now the integration one at ~15 min instead of the unit one at ~12.8. In exchange each PR that runs Windows takes two Windows runners instead of three and ~7 fewer runner-minutes: every leg pays its own setup (~1 min) and builds the workspace libraries its tests link against (~4 min even from sccache). That matters more while PRs queue for the account's concurrent-job limit. Co-Authored-By: Claude Opus 5.5 --- .github/workflows/ci.yml | 26 +++++++++++++------------- 1 file changed, 13 insertions(+), 13 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index dfe21e1498..823232cb3d 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -56,12 +56,13 @@ jobs: run: | # No macOS on PRs or main: the org's 5 macOS slots are kept for release builds, and # release.yml builds and tests macOS (a failing release is fixed and rebuilt). - # Windows tests run as three legs in parallel: the unit tests, and the integration tests - # split in two (nextest --partition). As one job they took ~28 min on the Windows runner; - # the legs take ~13, ~10 and ~11. + # Windows tests run as two legs in parallel: the unit tests and the integration tests. As + # one job they took ~28 min on the Windows runner; the legs take ~13 and ~15. Splitting + # the integration tests over a third runner (nextest --partition) saved ~2 min of wall + # time but cost ~7 runner-minutes and one more Windows slot per PR. linux='{"os":"ubuntu-latest","part":"all"}' win() { printf '{"os":"windows-latest","part":"%s"}' "$1"; } - all="[$linux,$(win unit),$(win integration-1),$(win integration-2)]" + all="[$linux,$(win unit),$(win integration)]" if [ "$EVENT" != pull_request ] || [ "$FULL" = true ]; then echo "legs=$all" >> "$GITHUB_OUTPUT" echo "web=true" >> "$GITHUB_OUTPUT" @@ -116,11 +117,11 @@ jobs: # Only main saves caches; PRs restore main's. Every PR used to save its own set (up to # 3.8 GB), usable by that PR only, so the repo's 10 GB cache budget kept evicting main's. # A failing test still saves: the cache holds compiled dependencies, not test results. - # The Windows legs share one key (same dependencies); the integration-2 leg saves it, + # The Windows legs share one key (same dependencies); the integration leg saves it, # because it also runs Clippy and so leaves Clippy's dependency metadata in it. - uses: Swatinem/rust-cache@v2 with: - save-if: ${{ github.ref == 'refs/heads/main' && (matrix.part == 'all' || matrix.part == 'integration-2') }} + save-if: ${{ github.ref == 'refs/heads/main' && (matrix.part == 'all' || matrix.part == 'integration') }} cache-on-failure: true # sccache caches the workspace crates' compiled libraries, which rust-cache leaves out: @@ -158,9 +159,9 @@ jobs: # never run (Clippy's `--all-targets` still checks they compile). nextest skips doctests, so # they get their own step. # - # On Windows the unit leg runs `--lib --bins` and the integration legs `--test '*'` (every - # integration-test target), half of the tests each; the feature selection stays the same, so - # all legs share the dependency cache. The `ci` profile retries the GPU tests + # On Windows the unit leg runs `--lib --bins` and the integration leg `--test '*'` (every + # integration-test target); the feature selection stays the same, so both legs share the + # dependency cache. The `ci` profile retries the GPU tests # (.config/nextest.toml). - name: Test env: @@ -168,8 +169,7 @@ jobs: run: | case "$PART" in unit) targets=(--lib --bins) ;; - integration-1) targets=(--test '*' --partition count:1/2) ;; - integration-2) targets=(--test '*' --partition count:2/2) ;; + integration) targets=(--test '*') ;; *) targets=(--lib --bins --tests) ;; esac cargo nextest run --profile ci --workspace --locked --features heif "${targets[@]}" --no-fail-fast @@ -180,11 +180,11 @@ jobs: run: cargo test --workspace --locked --features heif --doc - name: Clippy - if: matrix.part == 'all' || matrix.part == 'integration-2' + if: matrix.part == 'all' || matrix.part == 'integration' run: cargo clippy --workspace --all-targets --locked --features heif -- -D warnings - name: Dependency layering - if: matrix.part == 'all' || matrix.part == 'integration-2' + if: matrix.part == 'all' || matrix.part == 'integration' run: cargo xtask layers # docs/scorecard.md must match its sources (checklists, budgets, baseline, corpus floors, From e99f09b306f878f3dd0f637c253a14bd00ae1f16 Mon Sep 17 00:00:00 2001 From: Ivan Date: Wed, 14 Oct 2026 14:28:41 +0300 Subject: [PATCH 11/47] Tests: mac_menu_appkit answers nextest's --list The test runs without a harness (AppKit needs the main thread), so nothing answered the `--list` that cargo-nextest sends each test binary: on macOS the binary ran the whole test as its listing and nextest failed on the output. It now lists itself in libtest's terse format, and nothing for `--ignored`; a run passes `--exact `, which falls through to the test as before. Found by Zach running `cargo nextest run --workspace` on a Mac. Co-Authored-By: Claude Opus 5.5 --- apps/photocraft/tests/mac_menu_appkit.rs | 12 ++++++++++++ 1 file changed, 12 insertions(+) diff --git a/apps/photocraft/tests/mac_menu_appkit.rs b/apps/photocraft/tests/mac_menu_appkit.rs index feacb4bd3b..db55015943 100644 --- a/apps/photocraft/tests/mac_menu_appkit.rs +++ b/apps/photocraft/tests/mac_menu_appkit.rs @@ -22,6 +22,18 @@ fn main() { use photocraft_ui_egui::native_menu; use serde_json::json; + // Without a harness nothing answers cargo-nextest's `--list`, so it ran the whole test as its + // listing and failed on the output. List the one test, as libtest's terse format would, and + // nothing for `--ignored` (nextest asks twice). Running it passes ` --exact`, which falls + // through to the test below. + let args: Vec = std::env::args().collect(); + if args.iter().any(|a| a == "--list") { + if !args.iter().any(|a| a == "--ignored") { + println!("mac_menu_appkit: test"); + } + return; + } + let mtm = MainThreadMarker::new().expect("harness = false runs on the main thread"); let nsapp = NSApplication::sharedApplication(mtm); nsapp.finishLaunching(); From 4a930959393b0d6077b749301765f877d3dcb090 Mon Sep 17 00:00:00 2001 From: Ivan Date: Wed, 14 Oct 2026 14:29:13 +0300 Subject: [PATCH 12/47] CI: keep a test (windows-latest) check that reports the test result Splitting the Windows tests renamed their check from `test (windows-latest)` to one per leg. No status check is required on main today, but if one is added under the old name it would wait forever. A small job under that name now fails unless the platform pick and the whole test matrix passed (`needs` gives one result for all legs, so a failing Linux leg fails it too). It checks the result instead of exiting 0 under `if: always()`, which would pass with every leg red. Suggested by Zach in review. Co-Authored-By: Claude Opus 5.5 --- .github/workflows/ci.yml | 18 ++++++++++++++++++ 1 file changed, 18 insertions(+) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 823232cb3d..8552311bfd 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -193,6 +193,24 @@ jobs: if: runner.os == 'Linux' run: cargo xtask scorecard --check + # The Windows tests used to be one job named `test (windows-latest)`; they're now legs named + # `test (windows-latest, unit)` and `test (windows-latest, integration)`. This job keeps the old + # name reporting, so a required status check on it (none today) would keep working. It fails + # unless the whole test matrix passed, Linux included, since `needs` reports one result for all + # legs; when a PR runs no Windows legs it passes along the Linux result. + windows-gate: + name: test (windows-latest) + needs: [platforms, test] + if: always() + runs-on: ubuntu-latest + steps: + - env: + PLATFORMS: ${{ needs.platforms.result }} + TEST: ${{ needs.test.result }} + run: | + echo "platforms: $PLATFORMS, test: $TEST" + [ "$PLATFORMS" = success ] && [ "$TEST" = success ] + # The two other builds of the Linux tests, in parallel with the test job instead of after it (they # added ~10 min to its critical path). Each restores the test job's cache (`shared-key: test` is # that job's key) and saves none: the dependencies are the same, and the 10 GB cache budget is From b23a01aa8fc955aa1053b74873566c6c086c7e6b Mon Sep 17 00:00:00 2001 From: Ivan Date: Wed, 14 Oct 2026 16:03:09 +0300 Subject: [PATCH 13/47] Tests: postscript_only_style waits for the font scan selected_style reads the font engine through try_shared, which returns None while the system font scan is still running. Under cargo test the test passed only because an earlier test in the same process had already finished the scan; run on its own (nextest runs every test that way, and `cargo test -- --exact` does too) it saw no engine and got "Regular" instead of "SemiBold". It now waits for the scan with photocraft_text::shared(), as every_theme_shows_the_type_options does. On main, alone, it failed before this and passes after, under both cargo test and nextest. Co-Authored-By: Claude Opus 5.5 --- crates/ui-egui/src/type_tool_tests.rs | 3 +++ 1 file changed, 3 insertions(+) diff --git a/crates/ui-egui/src/type_tool_tests.rs b/crates/ui-egui/src/type_tool_tests.rs index 0f5be9e2fa..5f7ae0c542 100644 --- a/crates/ui-egui/src/type_tool_tests.rs +++ b/crates/ui-egui/src/type_tool_tests.rs @@ -1271,6 +1271,9 @@ fn font_styles_keep_metadata_names_and_numeric_labels() { #[test] fn postscript_only_style_shows_actual_subfamily() { + // selected_style reads the engine only once the font scan is done (try_shared); on its own, + // as nextest runs it, nothing else has finished the scan, so wait for it here. + let _ = photocraft_text::shared(); let style = photocraft_doc::text::CharStyle { font_family: "Inter".into(), postscript_name: Some("Inter-SemiBold".into()), ..Default::default() }; assert_eq!(super::selected_style(&style), "SemiBold"); } From 3bd9bc72a7be9a1861f5c228c18ac74ed783a9cd Mon Sep 17 00:00:00 2001 From: Ivan Date: Wed, 14 Oct 2026 19:22:14 +0300 Subject: [PATCH 14/47] Tests: monitor_macos answers nextest's --list monitor_macos is harness = false, so nothing answered cargo-nextest's discovery: `--list --format terse` ran the whole test and nextest failed to parse its output on macOS. List the one test, and nothing for `--ignored`, as mac_menu_appkit already does; `monitor_macos --exact` still runs the body. Co-Authored-By: Claude Opus 5.5 --- crates/tablet/tests/monitor_macos.rs | 10 ++++++++++ 1 file changed, 10 insertions(+) diff --git a/crates/tablet/tests/monitor_macos.rs b/crates/tablet/tests/monitor_macos.rs index 0a32923aa1..82f371cdb1 100644 --- a/crates/tablet/tests/monitor_macos.rs +++ b/crates/tablet/tests/monitor_macos.rs @@ -14,6 +14,16 @@ fn main() { use photocraft_tablet::Sample; use photocraft_tablet::macos::Monitor; + // Answer cargo-nextest's `--list` (and nothing for `--ignored`), as tests/mac_menu_appkit.rs + // in apps/photocraft does; `monitor_macos --exact` falls through to the test. + let args: Vec = std::env::args().collect(); + if args.iter().any(|a| a == "--list") { + if !args.iter().any(|a| a == "--ignored") { + println!("monitor_macos: test"); + } + return; + } + let mtm = MainThreadMarker::new().expect("harness = false runs on the main thread"); let app = NSApplication::sharedApplication(mtm); let event = |kind: CGEventType, pressure: f64, tilt: (f64, f64), subtype: CGEventMouseSubtype| { From f0f0d6f58a233c3b440e573316590e6d7f4e2d7f Mon Sep 17 00:00:00 2001 From: Ivan Date: Wed, 14 Oct 2026 19:22:15 +0300 Subject: [PATCH 15/47] CI: the gpu test group covers the other ui-egui wgpu tests The canvas|gpu|mip|type_tool name filter missed ui-egui unit tests that build a Harness with a real wgpu device: background_redraw's GPU tests, panel_docking's floating_group_titles_are_opaque_in_studio_themes and swap_arrow's rendered_glyph_has_a_left_head_and_a_lower_right_head. Name them in both overrides, so they share the 4-thread cap and get the CI retries. The other .wgpu() harnesses only render when a fixture env var is set, or are #[ignore]d. Co-Authored-By: Claude Opus 5.5 --- .config/nextest.toml | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/.config/nextest.toml b/.config/nextest.toml index 31d597451a..bfd4e0e689 100644 --- a/.config/nextest.toml +++ b/.config/nextest.toml @@ -7,17 +7,18 @@ # # GPU tests: each test is its own process here, so the in-process `gpu_lock()` mutexes no longer # serialize them. Too many concurrent wgpu devices time out (`PollError: ... Wait timed out`), so -# this group caps them at 4. +# this group caps them at 4. A ui-egui test that builds a `Harness` with `.wgpu()` has a real device: +# name it in the filter (both copies) unless its name already says canvas, gpu, mip or type_tool. [test-groups] gpu = { max-threads = 4 } [[profile.default.overrides]] -filter = 'package(photocraft-gpu) | (package(photocraft-ui-egui) & (kind(test) | test(/canvas|gpu|mip|type_tool/)))' +filter = 'package(photocraft-gpu) | (package(photocraft-ui-egui) & (kind(test) | test(/canvas|gpu|mip|type_tool|background_redraw::|floating_group_titles|swap_arrow::tests::rendered_glyph/)))' test-group = 'gpu' # CI (`--profile ci`): the GPU tests run on WARP, the software adapter, on the Windows runner, and # under load one occasionally hits that same timeout (1 in ~40 runs while measuring #2933). Retry # them; nextest reports a test that passes on retry as FLAKY, so it stays visible. [[profile.ci.overrides]] -filter = 'package(photocraft-gpu) | (package(photocraft-ui-egui) & (kind(test) | test(/canvas|gpu|mip|type_tool/)))' +filter = 'package(photocraft-gpu) | (package(photocraft-ui-egui) & (kind(test) | test(/canvas|gpu|mip|type_tool|background_redraw::|floating_group_titles|swap_arrow::tests::rendered_glyph/)))' retries = 2 From 691a022b12899e1287060f0ca4c94e793c3ac8bb Mon Sep 17 00:00:00 2001 From: Ivan Date: Wed, 14 Oct 2026 20:29:16 +0300 Subject: [PATCH 16/47] CI: skip the test (windows-latest) gate in cancelled runs With always() the gate also ran when a newer push cancelled the run, taking a runner slot only to report a red check on the superseded commit. !cancelled() still runs it when a test leg fails (a skipped required check would count as passing), but not in a cancelled run. Co-Authored-By: Claude Opus 5.5 --- .github/workflows/ci.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 8552311bfd..5489f07005 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -201,7 +201,7 @@ jobs: windows-gate: name: test (windows-latest) needs: [platforms, test] - if: always() + if: ${{ !cancelled() }} runs-on: ubuntu-latest steps: - env: From b2fafcf68ccca13e0236caa1f7f3d6add03a84b0 Mon Sep 17 00:00:00 2001 From: skylartaylor <26588250+skylartaylor@users.noreply.github.com> Date: Thu, 15 Oct 2026 04:27:54 +0200 Subject: [PATCH 17/47] ci: add comfortable runtime limits to unbounded jobs --- .github/workflows/ci.yml | 6 ++++++ .github/workflows/docs.yml | 1 + .github/workflows/packaging-lint.yml | 1 + 3 files changed, 8 insertions(+) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index bec29abaf5..07c11a4eb6 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -39,6 +39,7 @@ jobs: # platform: the app shell, UI, text and fonts, GPU, pen input, build scripts or the lockfile. # macOS is tested only by release.yml. platforms: + timeout-minutes: 10 name: select platforms runs-on: ubuntu-latest outputs: @@ -77,6 +78,7 @@ jobs: fi test: + timeout-minutes: 100 name: test (${{ matrix.os }}) needs: platforms runs-on: ${{ matrix.os }} @@ -158,6 +160,7 @@ jobs: run: cargo xtask scorecard --check corpus: + timeout-minutes: 30 # Real-file corpus tests (cargo feature `corpus`), on every PR and on main. The corpora are # pinned in xtask/src/corpus_pins.rs and sha256-verified (xtask/*.sha256) after both a cache # restore and a download; a missing corpus fails the tests. The cache key changes only when a @@ -190,6 +193,7 @@ jobs: run: cargo xtask test-corpus wasm: + timeout-minutes: 30 name: wasm32 check runs-on: ubuntu-latest steps: @@ -206,6 +210,7 @@ jobs: run: cargo xtask wasm web: + timeout-minutes: 60 name: web build and wasm size gate # PRs run it only when web-relevant files change (see `platforms`); main always does. needs: platforms @@ -250,6 +255,7 @@ jobs: } >> "$GITHUB_STEP_SUMMARY" fuzz: + timeout-minutes: 60 name: fuzz (${{ matrix.target.crate }}/${{ matrix.target.name }}) # Placeholder: manual trigger only until a nightly schedule is enabled. if: github.event_name == 'workflow_dispatch' && inputs.fuzz diff --git a/.github/workflows/docs.yml b/.github/workflows/docs.yml index 1d6c4c1f56..67e6b31894 100644 --- a/.github/workflows/docs.yml +++ b/.github/workflows/docs.yml @@ -27,6 +27,7 @@ concurrency: jobs: build: + timeout-minutes: 15 name: mdBook build runs-on: ubuntu-latest steps: diff --git a/.github/workflows/packaging-lint.yml b/.github/workflows/packaging-lint.yml index 272715dc81..1d110e28f2 100644 --- a/.github/workflows/packaging-lint.yml +++ b/.github/workflows/packaging-lint.yml @@ -23,6 +23,7 @@ jobs: uses: storytold/craft-common-actions/.github/workflows/packaging-lint.yml@main verified-appimage-inputs: + timeout-minutes: 10 name: Verified AppImage inputs runs-on: ubuntu-latest steps: From 9660dca2637acd1252859db0ccf11cf28d9f5fdb Mon Sep 17 00:00:00 2001 From: David Mann Date: Thu, 15 Oct 2026 14:22:13 +0200 Subject: [PATCH 18/47] CI: run clippy, layers and scorecard checks before the tests (#6119) They take about 1.5 min together but ran after all the test passes, so a clippy failure (15 s) showed red only 41-48 min into the job. Same steps, reordered: no cost on a green run. Co-Authored-By: Claude Opus 5.5 (1M context) --- .github/workflows/ci.yml | 26 ++++++++++++++------------ 1 file changed, 14 insertions(+), 12 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index dfc2564a59..a0b3360db3 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -111,10 +111,24 @@ jobs: save-if: ${{ github.ref == 'refs/heads/main' }} cache-on-failure: true + # The cheap checks run first: a format, lint, layering or stale-scorecard failure is red + # after a few minutes instead of after the test passes (#6119). Clippy restores main's cache. - name: Format if: runner.os == 'Linux' run: cargo fmt --all -- --check + - name: Clippy + run: cargo clippy --workspace --all-targets --locked --features heif -- -D warnings + + - name: Dependency layering + run: cargo xtask layers + + # docs/scorecard.md must match its sources (checklists, budgets, baseline, corpus floors, + # prefs audit). Static: it reads committed files only, no benchmarks run here. + - name: Scorecard up to date + if: runner.os == 'Linux' + run: cargo xtask scorecard --check + # `--features heif`: official builds open HEIC photos via the optional photocraft-heif crate. - name: Test run: cargo test --workspace --locked --features heif @@ -145,18 +159,6 @@ jobs: CRAFT_FONTS_REQUIRED: "1" run: cargo test --workspace --locked --features heif - - name: Clippy - run: cargo clippy --workspace --all-targets --locked --features heif -- -D warnings - - - name: Dependency layering - run: cargo xtask layers - - # docs/scorecard.md must match its sources (checklists, budgets, baseline, corpus floors, - # prefs audit). Static: it reads committed files only, no benchmarks run here. - - name: Scorecard up to date - if: runner.os == 'Linux' - run: cargo xtask scorecard --check - corpus: # Real-file corpus tests (cargo feature `corpus`), on every PR and on main. The corpora are # pinned in xtask/src/corpus_pins.rs and sha256-verified (xtask/*.sha256) after both a cache From e22c4ec7ab284c464d4340512e59d9d3c83e0e40 Mon Sep 17 00:00:00 2001 From: David Mann Date: Thu, 15 Oct 2026 14:40:15 +0200 Subject: [PATCH 19/47] CI: run the tests with cargo nextest (#6117) cargo test runs the ~258 test binaries one at a time; nextest runs them in parallel. Its new `ci` profile stops at the first failure (a red PR frees its runner early), fails a test that hangs for 10 minutes instead of letting it hold the runner, and repeats every failure at the end of the log; the GPU test group still applies. Same --workspace --locked --features heif selection, so the build is unchanged. Doctests stay on cargo test --doc. postscript_only_style_shows_actual_subfamily relied on another test in the same process having started the font scan; under nextest it runs alone, so it starts the scan itself (as #2933 does). Co-Authored-By: Claude Opus 5.5 (1M context) --- .config/nextest.toml | 11 ++++++++++- .github/workflows/ci.yml | 15 ++++++++++++--- crates/ui-egui/src/type_tool_tests.rs | 3 +++ docs/development.md | 2 +- 4 files changed, 26 insertions(+), 5 deletions(-) diff --git a/.config/nextest.toml b/.config/nextest.toml index 2585577ec1..a082d13515 100644 --- a/.config/nextest.toml +++ b/.config/nextest.toml @@ -1,5 +1,6 @@ # cargo-nextest (https://nexte.st): runs every test in its own process, in parallel across test -# binaries. Optional for local runs; `cargo test` keeps working and stays what CI runs (#2204). +# binaries. CI runs it with the `ci` profile below (#6117); locally it is optional and `cargo test` +# keeps working (#2204). # # cargo nextest run --workspace # all tests except doctests # cargo test --doc --workspace # nextest does not run doctests @@ -13,3 +14,11 @@ gpu = { max-threads = 4 } [[profile.default.overrides]] filter = 'package(photocraft-gpu) | (package(photocraft-ui-egui) & (kind(test) | test(/canvas|gpu|mip|type_tool/)))' test-group = 'gpu' + +# CI (`--profile ci`, .github/workflows/ci.yml). Stops at the first failure so a red PR frees its +# runner, fails a test that hangs instead of letting it hold the runner, and repeats every failure +# at the end of the log. The default profile's GPU override still applies. +[profile.ci] +fail-fast = true +slow-timeout = { period = "60s", terminate-after = 10 } +failure-output = "immediate-final" diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index dfc2564a59..31323da703 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -115,15 +115,24 @@ jobs: if: runner.os == 'Linux' run: cargo fmt --all -- --check + # cargo-nextest runs the test binaries in parallel (`cargo test` runs them one at a time) and + # its `ci` profile (.config/nextest.toml) stops at the first failure and fails hung tests. + - uses: taiki-e/install-action@83ac0ad63c0167e6f06796fab0fce28db1bf3db0 # v2.87.22 + with: + tool: cargo-nextest@0.9.146 + # `--features heif`: official builds open HEIC photos via the optional photocraft-heif crate. + # nextest doesn't run doctests, so `cargo test --doc` does, with the same selection. - name: Test - run: cargo test --workspace --locked --features heif + run: | + cargo nextest run --workspace --locked --features heif --profile ci + cargo test --doc --workspace --locked --features heif # The same codecs without the optional decoder: HEIC must stay a clear "not in this build" # error for distributors who leave it out. - name: Test without heif if: runner.os == 'Linux' - run: cargo test -p photocraft-heif -p photocraft-codecs -p photocraft-io --locked + run: cargo nextest run -p photocraft-heif -p photocraft-codecs -p photocraft-io --locked --profile ci # The same tests built with the optional craft-fonts input (crates/text/build.rs), so the # Japanese font tests that skip without it run here. Linux only. Keep the exact `--workspace @@ -143,7 +152,7 @@ jobs: env: CRAFT_FONTS_DIR: ${{ github.workspace }}/craft-fonts CRAFT_FONTS_REQUIRED: "1" - run: cargo test --workspace --locked --features heif + run: cargo nextest run --workspace --locked --features heif --profile ci - name: Clippy run: cargo clippy --workspace --all-targets --locked --features heif -- -D warnings diff --git a/crates/ui-egui/src/type_tool_tests.rs b/crates/ui-egui/src/type_tool_tests.rs index f56829c6d1..c7b54c8871 100644 --- a/crates/ui-egui/src/type_tool_tests.rs +++ b/crates/ui-egui/src/type_tool_tests.rs @@ -1288,6 +1288,9 @@ fn font_styles_keep_metadata_names_and_numeric_labels() { #[test] fn postscript_only_style_shows_actual_subfamily() { + // selected_style reads the engine only once the font scan is done (try_shared). Under nextest + // this test runs in a process of its own, where nothing else has started the scan, so start it. + let _ = photocraft_text::shared(); let style = photocraft_doc::text::CharStyle { font_family: "Inter".into(), postscript_name: Some("Inter-SemiBold".into()), ..Default::default() }; assert_eq!(super::selected_style(&style), "SemiBold"); } diff --git a/docs/development.md b/docs/development.md index ceb679f5f7..3e7f5fb66e 100644 --- a/docs/development.md +++ b/docs/development.md @@ -59,7 +59,7 @@ cargo nextest run --workspace # parallel across binaries (cargo ins cargo test --doc --workspace # nextest does not run doctests ``` -[cargo-nextest](https://nexte.st) runs each test in its own process. `.config/nextest.toml` puts the GPU tests (`photocraft-gpu`, and ui-egui's integration and canvas/GPU tests) in a group of at most 4 at a time, because each process opens its own wgpu device and the in-process `gpu_lock()` no longer serializes them. On a 32-thread Windows machine the full workspace went from 297 s (`cargo test`) to about 120 s (#2204). Ignored tests run with `cargo nextest run --run-ignored only`. CI still runs `cargo test`, and a full `cargo test` stays the final check before a PR. +[cargo-nextest](https://nexte.st) runs each test in its own process. `.config/nextest.toml` puts the GPU tests (`photocraft-gpu`, and ui-egui's integration and canvas/GPU tests) in a group of at most 4 at a time, because each process opens its own wgpu device and the in-process `gpu_lock()` no longer serializes them. On a 32-thread Windows machine the full workspace went from 297 s (`cargo test`) to about 120 s (#2204). Ignored tests run with `cargo nextest run --run-ignored only`. CI runs it with `--profile ci` (fail-fast, a 10-minute hang timeout), plus `cargo test --doc` for the doctests (#6117); a full `cargo test` stays the final check before a PR. ## Fonts (craft-fonts) From caa28cb7ab5f4ca92444603b41b81164fb4e36c0 Mon Sep 17 00:00:00 2001 From: David Mann Date: Thu, 15 Oct 2026 14:41:12 +0200 Subject: [PATCH 20/47] CI: run only the font tests in the craft-fonts pass (#6120) The pass reran every test in the workspace, but CRAFT_FONTS is only read in photocraft-text, photocraft-io (svg) and photocraft-ui-egui. The build selection stays the same (the disk warning above still applies); a nextest filter runs those three packages' tests: ~3,800 of ~8,900. Co-Authored-By: Claude Opus 5.5 (1M context) --- .github/workflows/ci.yml | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 31323da703..e534abee3b 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -139,6 +139,8 @@ jobs: # --features heif` of the Test step: any other package or feature selection resolves a # different feature set, recompiles the whole dependency graph into the same target dir and # runs the runner out of disk (as `-p photocraft-text -p photocraft-ui-egui` did after #800). + # Only the tests of the crates that read `CRAFT_FONTS` run (the `-E` filter changes what runs, + # not what is built); engine and the apps only pass text on to them (#6120). - name: Check out craft-fonts if: runner.os == 'Linux' uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4 @@ -152,7 +154,9 @@ jobs: env: CRAFT_FONTS_DIR: ${{ github.workspace }}/craft-fonts CRAFT_FONTS_REQUIRED: "1" - run: cargo nextest run --workspace --locked --features heif --profile ci + run: >- + cargo nextest run --workspace --locked --features heif --profile ci + -E 'package(photocraft-text) | package(photocraft-io) | package(photocraft-ui-egui)' - name: Clippy run: cargo clippy --workspace --all-targets --locked --features heif -- -D warnings From 4e7a17cd5d5723313f459753663d0d155f047e19 Mon Sep 17 00:00:00 2001 From: skylar taylor-barrick Date: Thu, 15 Oct 2026 08:18:21 -0600 Subject: [PATCH 21/47] fix(compose): serialize effect identity cache misses Keep effect-identity memo lookup, pure hashing and insertion atomic across concurrent render callers. Preserve existing identities and add a contention regression with eight synchronized callers. --- crates/compose/src/lib.rs | 12 ++++++------ crates/compose/src/tests.rs | 24 ++++++++++++++++++++++++ 2 files changed, 30 insertions(+), 6 deletions(-) diff --git a/crates/compose/src/lib.rs b/crates/compose/src/lib.rs index f688ffedb3..3cf2f1dc19 100644 --- a/crates/compose/src/lib.rs +++ b/crates/compose/src/lib.rs @@ -563,14 +563,14 @@ impl LayerKeys { fn identity(&self, layer: &Layer, doc: Option<&Document>) -> u64 { let addr = std::ptr::from_ref(layer) as usize; let in_doc = doc.is_some_and(|d| self.in_doc.get_or_init(|| doc_layer_addresses(d)).contains(&addr)); - if in_doc && let Some(id) = self.ids.lock().unwrap_or_else(|e| e.into_inner()).get(&addr) { - return *id; - } - let id = self.compute(layer); if in_doc { - self.ids.lock().unwrap_or_else(|e| e.into_inner()).insert(addr, id); + let mut ids = self.ids.lock().unwrap_or_else(|e| e.into_inner()); + // Computing an identity only hashes immutable layer data; it does not run Rayon + // work or re-enter this memo. Keep the miss and insertion atomic across callers. + *ids.entry(addr).or_insert_with(|| self.compute(layer)) + } else { + self.compute(layer) } - id } fn compute(&self, layer: &Layer) -> u64 { diff --git a/crates/compose/src/tests.rs b/crates/compose/src/tests.rs index eb4b250728..ddd4871548 100644 --- a/crates/compose/src/tests.rs +++ b/crates/compose/src/tests.rs @@ -3190,3 +3190,27 @@ fn effect_cache_keeps_no_pixels_of_a_dropped_layer() { assert_eq!(std::sync::Arc::strong_count(&own), 1); assert_eq!(std::sync::Arc::strong_count(&child), 1); } + +// Concurrent callers share one memoized effect identity. +#[test] +fn concurrent_effect_identity_misses_compute_once() { + let mut doc = identity_doc(SampleType::U8); + // Make the existing pure identity hash long enough to expose overlapping misses. + doc.layers[1].effects.items = vec![Effect::DropShadow(shadow(4.0, 120.0)); 512]; + let layer = &doc.layers[1]; + let keys = LayerKeys::default(); + let start = std::sync::Barrier::new(8); + std::thread::scope(|scope| { + let handles: Vec<_> = (0..8) + .map(|_| { + scope.spawn(|| { + start.wait(); + keys.identity(layer, Some(&doc)) + }) + }) + .collect(); + let ids: Vec<_> = handles.into_iter().map(|h| h.join().unwrap()).collect(); + assert!(ids.iter().all(|id| *id == ids[0])); + }); + assert_eq!(keys.built.load(std::sync::atomic::Ordering::Relaxed), 1, "concurrent cache misses must compute one memoized identity"); +} From c06eca9b681ff362bd83c0ca0cf189f1f6e42153 Mon Sep 17 00:00:00 2001 From: midasmatterhorn <248507632+midasmatterhorn@users.noreply.github.com> Date: Thu, 15 Oct 2026 20:23:57 +0300 Subject: [PATCH 22/47] fix(psd): preserve PSB foreign-order block lengths Choose PSB block length width from the canonical big-endian key before converting the key for little-endian TIFF output. Preserve foreign payloads, padding and conversion of following blocks. Addresses #5599, finding 2 only. Fixes #6104. --- crates/psd/src/tiff.rs | 32 +++++++++++++++++++++++++------- 1 file changed, 25 insertions(+), 7 deletions(-) diff --git a/crates/psd/src/tiff.rs b/crates/psd/src/tiff.rs index 67142921ba..31c85d4885 100644 --- a/crates/psd/src/tiff.rs +++ b/crates/psd/src/tiff.rs @@ -681,13 +681,12 @@ pub mod transcode { if !self.from.is_little() && TaggedBlock::is_foreign_order_signature(self.r.peek_rest()) { let sig = self.r.array::<4>()?; let mut key = self.r.array::<4>()?; - let long = uses_long_length(self.version, &{ - let mut k = key; - if self.to.is_little() { - k.reverse(); - } - k - }); + // Only reached with `from == Big`: the key is canonical here whatever the + // target order. `TaggedBlock::write` sizes the length field from that same key. + // Reversing it for the target order before the check + // asked about a key `PSB_LONG_KEYS` never contains, so a PSB long key's 8-byte + // length was read as 4 and every later block was copied unconverted. + let long = uses_long_length(self.version, &key); let len = self.r.len_field(long)?; let data = self.r.bytes_u64(len)?; if self.to.is_little() { @@ -2211,6 +2210,25 @@ mod tests { assert_eq!(ImageSourceData::from_bytes(&big).unwrap().0.global_blocks, back.global_blocks); } + /// A PSB keeps `Mt16`/`Mt32`/`Mtrn`/`Alph` blocks of unknown layout verbatim, and their + /// length field is 8 bytes (a PSB long key). Writing them into a little-endian TIFF must + /// read that width; the old code looked the key up after reversing it for the target + /// order, which never matches `PSB_LONG_KEYS`. + #[test] + fn psb_foreign_order_long_key_roundtrips() { + let d = ImageSourceData { + version: Version::Psb, + byte_order: ByteOrder::Little, + global_blocks: vec![TaggedBlock { signature: *b"MIB8", key: *b"Mt16", data: vec![9, 8, 7, 6, 5, 4], padding: None }], + ..Default::default() + }; + let (little, w) = d.to_bytes(ByteOrder::Little).unwrap(); + assert!(w.is_empty(), "{w:?}"); + let (back, w) = ImageSourceData::from_bytes(&little).unwrap(); + assert!(w.is_empty(), "{w:?}"); + assert_eq!(back.global_blocks, d.global_blocks, "the 8-byte length and the bytes survive"); + } + #[test] fn rle_counts_follow_the_container_and_samples_do_not() { let file = testgen::layered(Version::Psd, ColorMode::Rgb, 16, Compression::Rle); From bcf2cefd4b2a215167f443933c25368e702b31a2 Mon Sep 17 00:00:00 2001 From: skylartaylor <26588250+skylartaylor@users.noreply.github.com> Date: Thu, 15 Oct 2026 19:56:43 +0200 Subject: [PATCH 23/47] ci: preserve nextest failures on Windows --- .github/workflows/ci.yml | 8 +++++--- 1 file changed, 5 insertions(+), 3 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 192a13d8b9..35ba5ea9a9 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -139,10 +139,12 @@ jobs: # `--features heif`: official builds open HEIC photos via the optional photocraft-heif crate. # nextest doesn't run doctests, so `cargo test --doc` does, with the same selection. + # Separate native commands into steps so Windows reports either command's failure. - name: Test - run: | - cargo nextest run --workspace --locked --features heif --profile ci - cargo test --doc --workspace --locked --features heif + run: cargo nextest run --workspace --locked --features heif --profile ci + + - name: Doctests + run: cargo test --doc --workspace --locked --features heif # The same codecs without the optional decoder: HEIC must stay a clear "not in this build" # error for distributors who leave it out. From 99083fca4c151f3f02c808de6ca865b810c3cac4 Mon Sep 17 00:00:00 2001 From: Zuujis <160198409+zuujiscom@users.noreply.github.com> Date: Thu, 15 Oct 2026 14:11:42 -0500 Subject: [PATCH 24/47] =?UTF-8?q?Quick=20Mask:=20=E2=8C=98I=20inverts=20th?= =?UTF-8?q?e=20whole=20mask,=20unstored=20black=20included=20(#6095)=20(#6?= =?UTF-8?q?108)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * Quick Mask: ⌘I inverts the whole mask, unstored black included (#6095) Image › Adjustments on an alpha channel or the Quick Mask ran on the surface's stored content only. The black outside a selection-made Quick Mask (or a new black alpha channel) is the surface default, never stored, so Invert left it black: the whole mask turned red and leaving Quick Mask selected nothing. Channel targets now go through `pixels::adjust_mask`, as layer masks do, which adjusts the default pixel too. Co-Authored-By: Claude Opus 5.5 * Quick Mask: test ⌘I with Color Indicates Selected Areas too (#6095) A rectangle comes back as everything but the rectangle, and with nothing selected ⌘I selects everything. Both failed on main. Co-Authored-By: Claude Opus 5.5 --------- Co-authored-by: Claude Opus 5.5 --- crates/engine/src/adjust_cmds/tests.rs | 50 ++++++++++++++++++++++++++ crates/engine/src/commands.rs | 6 ++-- 2 files changed, 53 insertions(+), 3 deletions(-) diff --git a/crates/engine/src/adjust_cmds/tests.rs b/crates/engine/src/adjust_cmds/tests.rs index 80e97b9f34..d831fbf448 100644 --- a/crates/engine/src/adjust_cmds/tests.rs +++ b/crates/engine/src/adjust_cmds/tests.rs @@ -661,3 +661,53 @@ fn replace_color_localized_clusters_only_change_the_colour_near_the_sample() { let err = s.execute("image.adjustments.replaceColor", json!({"points": [[1]]})).unwrap_err(); assert!(err.to_string().contains("bad point"), "{err}"); } + +/// #6095: ⌘I in Quick Mask inverts the whole mask, not only where it was white, so leaving Quick +/// Mask selects everything but the original rectangle, whichever way the colour reads. With +/// Color Indicates Selected Areas and nothing selected, it selects everything. A black alpha +/// channel inverts to white. +#[test] +fn invert_turns_the_black_of_quick_mask_and_alpha_channels_white() { + for depth in DEPTHS { + let mut s = session(depth, "rgb"); + let pixels = rgba(&s, 2, 2); + s.execute("select.rect", json!({"x": 10, "y": 8, "width": 20, "height": 10})).unwrap(); + s.execute("select.editInQuickMaskMode", json!({"on": true})).unwrap(); + s.execute("image.adjustments.invert", json!({"target": "quickMask"})).unwrap(); + let q = &doc(&s).quick_mask.as_ref().unwrap().surface; + let (inside, outside) = (q.sample_channel(15, 12, 0), q.sample_channel(2, 2, 0)); + assert!(inside < 0.01 && outside > 0.99, "{depth}-bit Quick Mask inverted: inside {inside}, outside {outside}"); + s.execute("select.editInQuickMaskMode", json!({"on": false})).unwrap(); + let sel = doc(&s).selection.as_ref().expect("an inverse selection after Quick Mask"); + let (inside, outside) = (sel.sample_channel(15, 12, 0), sel.sample_channel(2, 2, 0)); + assert!(inside < 0.01 && outside > 0.99, "{depth}-bit selection: inside {inside}, outside {outside}"); + assert_eq!(rgba(&s, 2, 2), pixels, "{depth}-bit: the layer is untouched"); + + s.execute("select.deselect", json!({})).unwrap(); + s.execute("channel.new", json!({"name": "Black", "fill": "black"})).unwrap(); + s.execute("image.adjustments.invert", json!({"target": {"channel": 0}})).unwrap(); + let a = doc(&s).channels[0].surface.sample_channel(40, 30, 0); + assert!(a > 0.99, "{depth}-bit black alpha channel inverts to white ({a})"); + s.undo(); + assert!(doc(&s).channels[0].surface.sample_channel(40, 30, 0) < 0.01, "{depth}-bit: undo restores the black"); + + // Color Indicates Selected Areas: a rectangle comes back inverted, and nothing selected + // comes back as everything selected. + for (start, inside, outside) in [("rectangle", 0.0, 1.0), ("nothing", 1.0, 1.0)] { + let mut s = session(depth, "rgb"); + s.execute("channel.options", json!({"channel": "quickMask", "indicates": "selected"})).unwrap(); + if start == "rectangle" { + s.execute("select.rect", json!({"x": 10, "y": 8, "width": 20, "height": 10})).unwrap(); + } + s.execute("select.editInQuickMaskMode", json!({"on": true})).unwrap(); + s.execute("image.adjustments.invert", json!({"target": "quickMask"})).unwrap(); + s.execute("select.editInQuickMaskMode", json!({"on": false})).unwrap(); + let sel = doc(&s).selection.as_ref().unwrap_or_else(|| panic!("{depth}-bit Selected Areas, {start}: a selection after Quick Mask")); + let got = (sel.sample_channel(15, 12, 0), sel.sample_channel(2, 2, 0)); + assert!( + (got.0 - inside).abs() < 0.01 && (got.1 - outside).abs() < 0.01, + "{depth}-bit Selected Areas, {start}: selection (inside, outside) {got:?}" + ); + } + } +} diff --git a/crates/engine/src/commands.rs b/crates/engine/src/commands.rs index 7ddad80737..a05c5b060c 100644 --- a/crates/engine/src/commands.rs +++ b/crates/engine/src/commands.rs @@ -381,12 +381,12 @@ fn destructive_adjust(s: &mut Session, label: &str, adj: Adjustment, p: &Value) }); } if crate::channel_cmds::is_channel_target(p) { - // Alpha channel / Quick Mask target: the adjustment runs on the grayscale channel. + // Alpha channel / Quick Mask target: the adjustment runs on the grayscale channel as on + // a layer mask, its unstored black included, so ⌘I in Quick Mask inverts it all (#6095). return s.edit(label, |doc, _| { let sel = doc.selection.clone(); if let Some(surf) = crate::channel_cmds::channel_surface_for_filter(doc, None, p)? { - pixels::adjust_surface(surf, &adj, sel.as_ref(), ColorMode::Grayscale); - surf.prune(); + pixels::adjust_mask(surf, &adj, sel.as_ref()); } Ok(Value::Null) }); From 1b666f4790f9f2e9907218a8e786a3af5305f200 Mon Sep 17 00:00:00 2001 From: Khawar Zamman Wani Date: Fri, 16 Oct 2026 00:42:35 +0530 Subject: [PATCH 25/47] Pen: Auto Add/Delete on the path being drawn (#6140) (#6172) While a path was being drawn, the Pen's Auto Add/Delete was off, so adding a point meant finishing the path and switching to Add Anchor Point. Now a click on a segment of the path in progress adds an anchor, a click on a middle anchor deletes it, and drawing carries on from the last anchor. The first anchor still closes the path and the last still breaks its handle. The hover highlight draws over the path in progress, and the rubber band hides where a click adds or deletes instead of placing an anchor. Claude-Session: https://claude.ai/code/session_01JFTxZ57wCmWEDXxCn1Zuni Co-authored-by: Claude Opus 5.5 --- crates/ui-egui/src/anchor_tools.rs | 66 +++++++++++++++--------- crates/ui-egui/src/anchor_tools_tests.rs | 64 +++++++++++++++++++++++ crates/ui-egui/src/vector_ui.rs | 10 +++- docs/ui-parity.md | 5 +- 4 files changed, 118 insertions(+), 27 deletions(-) diff --git a/crates/ui-egui/src/anchor_tools.rs b/crates/ui-egui/src/anchor_tools.rs index 9dc8b6cfe5..e6f9ac72d3 100644 --- a/crates/ui-egui/src/anchor_tools.rs +++ b/crates/ui-egui/src/anchor_tools.rs @@ -7,9 +7,10 @@ //! * Delete Anchor Point: hovering an anchor highlights it; a click removes it //! (`path.deleteAnchor`), joining its neighbours. //! * ⌥ swaps the two tools and ⌘/Ctrl is Direct Selection (`direct_select`) while held. -//! * The Pen does both with Auto Add/Delete on (its options bar, on by default, #4144), on the -//! active path only and while no path is being drawn: a click on a segment adds, a click on an -//! anchor deletes, except an open subpath's end points; ⇧ turns it off while held. +//! * The Pen does both with Auto Add/Delete on (its options bar, on by default, #4144): a click on +//! a segment adds, a click on an anchor deletes, except an open subpath's end points; ⇧ turns it +//! off while held. It edits the active path, or while a path is being drawn that path (#6140), +//! and drawing carries on from its last anchor. //! //! Each click is one command and one history step; a click on nothing it can edit does nothing. @@ -48,16 +49,26 @@ fn target(app: &PhotocraftApp, tool: Tool, p: [f64; 2]) -> Option<(PathRef, Path /// The Pen's Auto Add/Delete (#4144): what a Pen click at `p` does to the active path instead of /// starting a new one, as the anchor tool that does it (Add Anchor Point on a segment, Delete -/// Anchor Point on an anchor), the path and the hit. `None` when the option is off, ⇧, ⌥ or ⌘ is -/// held, a path is being drawn, or the click hits nothing it edits. The end points of an open -/// subpath are left to the Pen, as in Photoshop. +/// Anchor Point on an anchor), the path and the hit. While a path is being drawn it edits that +/// path only (#6140): a click anywhere else places the next anchor. `None` when the option is off, +/// ⇧, ⌥ or ⌘ is held, an anchor's handles are being pulled out, or the click hits nothing it edits. +/// The end points of an open subpath are left to the Pen, as in Photoshop: on the path being +/// drawn, the first closes it and the last breaks its handle. pub fn pen_auto(app: &PhotocraftApp, tool: Tool, mods: Modifiers, p: [f64; 2]) -> Option<(Tool, PathRef, Path, Hit)> { - if tool != Tool::Pen || !app.ui.tool_options.pen_auto_add_delete || app.ui.pen.is_some() || mods.shift || mods.alt || mods.command { + if tool != Tool::Pen || !app.ui.tool_options.pen_auto_add_delete || mods.shift || mods.alt || mods.command { return None; } let tol = 6.0 / f64::from(app.point_zoom().max(0.01)); - let (r, path, hit) = - crate::direct_select::active_paths(app).into_iter().find_map(|(r, path)| edit::hit(&path, Point::new(p[0], p[1]), tol, &[]).map(|h| (r, path, h)))?; + let at = Point::new(p[0], p[1]); + let (r, path, hit) = match app.ui.pen.as_ref() { + Some(pen) if pen.dragging || pen.curvature => return None, + Some(pen) => { + let path = crate::vector_ui::pen_path(pen); + let hit = edit::hit(&path, at, tol, &[])?; + (PathRef::Pen, path, hit) + } + None => crate::direct_select::active_paths(app).into_iter().find_map(|(r, path)| edit::hit(&path, at, tol, &[]).map(|h| (r, path, h)))?, + }; let tool = match hit { Hit::Segment(..) => Tool::AddAnchorPoint, Hit::Anchor([s, k]) => { @@ -76,10 +87,14 @@ pub fn pen_auto(app: &PhotocraftApp, tool: Tool, mods: Modifiers, p: [f64; 2]) - /// when they aren't theirs. pub fn pointer(app: &mut PhotocraftApp, ev: ToolEvent, mods: Modifiers) -> bool { if let ToolEvent::Down { x, y, .. } = ev - && let Some((_, r, _, hit)) = pen_auto(app, app.ui.tool, mods, [x, y]) + && let Some((_, r, path, hit)) = pen_auto(app, app.ui.tool, mods, [x, y]) { - // The release finds no Pen path to finish, so the Pen draws nothing. - apply(app, r, hit); + // The release finds no Pen drag to finish, so the Pen draws nothing. + if r == PathRef::Pen { + edit_pen(app, path, hit); + } else { + apply(app, r, hit); + } return true; } let Some(tool) = effective(app.ui.tool, mods) else { return false }; @@ -90,23 +105,28 @@ pub fn pointer(app: &mut PhotocraftApp, ev: ToolEvent, mods: Modifiers) -> bool } fn click(app: &mut PhotocraftApp, tool: Tool, p: [f64; 2]) { - let Some((r, mut path, hit)) = target(app, tool, p) else { return }; + let Some((r, path, hit)) = target(app, tool, p) else { return }; if r == PathRef::Pen { - // The Pen path being drawn isn't in the document yet: it takes the edit directly. - let edited = match hit { - Hit::Segment(k, t) => edit::add_anchor(&mut path, k, t).is_ok(), - Hit::Anchor(k) => edit::delete_anchor(&mut path, k).is_ok(), - Hit::Handle(..) => false, - }; - if edited { - app.ui.direct_selection = DirectSelection { target: Some(r), ..Default::default() }; - crate::vector_ui::pen_replace(app, &path); - } + edit_pen(app, path, hit); return; } apply(app, r, hit); } +/// Add or delete an anchor of the Pen path being drawn (`path`, from `pen_path`). It isn't in the +/// document yet, so it takes the edit directly; drawing carries on from its last anchor. +fn edit_pen(app: &mut PhotocraftApp, mut path: Path, hit: Hit) { + let edited = match hit { + Hit::Segment(k, t) => edit::add_anchor(&mut path, k, t).is_ok(), + Hit::Anchor(k) => edit::delete_anchor(&mut path, k).is_ok(), + Hit::Handle(..) => false, + }; + if edited { + app.ui.direct_selection = DirectSelection { target: Some(PathRef::Pen), ..Default::default() }; + crate::vector_ui::pen_replace(app, &path); + } +} + /// Add an anchor where `hit` is on a segment of `r`, or delete the anchor it is on: one command. fn apply(app: &mut PhotocraftApp, r: PathRef, hit: Hit) { let (id, mut params) = match hit { diff --git a/crates/ui-egui/src/anchor_tools_tests.rs b/crates/ui-egui/src/anchor_tools_tests.rs index 87c045eba9..c21cc5aaf6 100644 --- a/crates/ui-egui/src/anchor_tools_tests.rs +++ b/crates/ui-egui/src/anchor_tools_tests.rs @@ -230,3 +230,67 @@ fn the_pen_cursor_shows_what_auto_add_delete_would_do() { app.ui.tool_options.pen_auto_add_delete = false; assert_eq!(badge(&app, Tool::Pen, Modifiers::NONE), None); } + +fn pen_anchors(app: &PhotocraftApp) -> Vec<[f64; 2]> { + app.ui.pen.as_ref().map(|p| p.knots.iter().map(|k| k[0]).collect()).unwrap_or_default() +} + +/// #6140: while drawing, a Pen click on the path being drawn adds an anchor on a segment or +/// deletes a middle anchor, and drawing carries on from the last anchor. Nothing reaches History. +#[test] +fn the_pen_adds_and_deletes_anchors_on_the_path_being_drawn() { + let mut app = app(Tool::Pen); + let before = steps(&app); + for at in [[40.0, 40.0], [120.0, 40.0], [120.0, 120.0]] { + click(&mut app, at, Modifiers::NONE); + } + click(&mut app, [80.0, 40.0], Modifiers::NONE); + assert_eq!(pen_anchors(&app), vec![[40.0, 40.0], [80.0, 40.0], [120.0, 40.0], [120.0, 120.0]], "added on the first segment"); + click(&mut app, [40.0, 120.0], Modifiers::NONE); + assert_eq!(pen_anchors(&app).last(), Some(&[40.0, 120.0]), "the next click carries on from the last anchor"); + assert_eq!(pen_anchors(&app).len(), 5); + + click(&mut app, [120.0, 40.0], Modifiers::NONE); + assert_eq!(pen_anchors(&app), vec![[40.0, 40.0], [80.0, 40.0], [120.0, 120.0], [40.0, 120.0]], "a middle anchor is deleted"); + assert_eq!(steps(&app), before, "the path being drawn is not in the document yet"); +} + +/// The path being drawn keeps its end points (the first closes it, the last breaks its handle), ⇧ +/// or the option turned off places an anchor on its segment, and another path takes no edit. +#[test] +fn the_pen_draws_on_the_path_being_drawn_where_auto_add_delete_does_not_apply() { + let mut app = app(Tool::Pen); + with_path(&mut app); + let work_before = work(&app); + for at in [[20.0, 160.0], [180.0, 160.0], [180.0, 180.0]] { + click(&mut app, at, Modifiers::NONE); + } + click(&mut app, [180.0, 180.0], Modifiers::NONE); + assert_eq!(pen_anchors(&app).len(), 3, "a click on the last anchor breaks its handle"); + click(&mut app, [100.0, 160.0], Modifiers::SHIFT); + assert_eq!(pen_anchors(&app).len(), 4, "⇧ places an anchor"); + app.ui.tool_options.pen_auto_add_delete = false; + click(&mut app, [100.0, 160.0], Modifiers::NONE); + assert_eq!(pen_anchors(&app).len(), 5, "with the option off the Pen places an anchor"); + app.ui.tool_options.pen_auto_add_delete = true; + click(&mut app, [80.0, 120.0], Modifiers::NONE); + assert_eq!((pen_anchors(&app).len(), work(&app)), (6, work_before), "another path takes no edit while drawing"); + click(&mut app, [20.0, 160.0], Modifiers::NONE); + assert!(app.ui.pen.is_none(), "the first anchor closes the path"); +} + +#[test] +fn the_pen_cursor_shows_auto_add_delete_on_the_path_being_drawn() { + use crate::tool_feedback::{Badge, badge}; + let mut app = app(Tool::Pen); + for at in [[40.0, 40.0], [120.0, 40.0], [120.0, 120.0]] { + click(&mut app, at, Modifiers::NONE); + } + let cases = [([80.0, 40.0], Some(Badge::Add)), ([120.0, 40.0], Some(Badge::Subtract)), ([40.0, 40.0], None), ([120.0, 120.0], None), ([60.0, 100.0], None)]; + for (at, want) in cases { + app.hover_doc = Some(at); + assert_eq!(badge(&app, Tool::Pen, Modifiers::NONE), want, "{at:?}"); + } + app.hover_doc = Some([80.0, 40.0]); + assert_eq!(badge(&app, Tool::Pen, Modifiers::SHIFT), None); +} diff --git a/crates/ui-egui/src/vector_ui.rs b/crates/ui-egui/src/vector_ui.rs index 01bb3c133b..8b5e914af2 100644 --- a/crates/ui-egui/src/vector_ui.rs +++ b/crates/ui-egui/src/vector_ui.rs @@ -1016,8 +1016,6 @@ pub fn draw_overlay(app: &PhotocraftApp, painter: &egui::Painter, xf: &ViewXform if let Some((_, p)) = targeted_vector_mask(app) { draw_path(&p, tool == Tool::PathSelection); } - // The segment the Pen's Auto Add/Delete would add an anchor to, or the anchor it would delete. - crate::anchor_tools::draw_pen_hover(app, painter, &to_scr, accent, painter.ctx().input(|i| i.modifiers)); crate::corner_radius::draw(app, painter, &to_scr, accent); } // Pen path in progress, with handles of the last knot and a rubber band to the pointer. While @@ -1038,9 +1036,12 @@ pub fn draw_overlay(app: &PhotocraftApp, painter: &egui::Painter, xf: &ViewXform pts.extend(curve_points(w[0][0], w[0][2], w[1][1], w[1][0], &to_scr)); } painter.add(egui::Shape::line(pts, Stroke::new(1.5, accent))); + // No rubber band where a click adds or deletes an anchor of this path instead (#6140). + let auto = app.hover_doc.is_some_and(|h| crate::anchor_tools::pen_auto(app, tool, painter.ctx().input(|i| i.modifiers), h).is_some()); if let (Some(last), Some(h)) = (pen.knots.last(), app.hover_doc) && !pen.dragging && pen.moving.is_none() + && !auto { if pen.curvature && pen.knots.len() >= 2 { // The Curvature Pen previews the curve the next click makes through the pointer. @@ -1079,6 +1080,11 @@ pub fn draw_overlay(app: &PhotocraftApp, painter: &egui::Painter, xf: &ViewXform painter.circle_stroke(c + vec2(11.0, 11.0), 3.5, Stroke::new(1.5, accent)); } } + // The segment the Pen's Auto Add/Delete would add an anchor to, or the anchor it would delete, + // over the path being drawn. While drawing, the other paths can be hidden: it edits none of them. + if paths || app.ui.pen.is_some() { + crate::anchor_tools::draw_pen_hover(app, painter, &to_scr, accent, painter.ctx().input(|i| i.modifiers)); + } } // --------------------------------------------------------------------------------------------- diff --git a/docs/ui-parity.md b/docs/ui-parity.md index 6edff953df..793f09840c 100644 --- a/docs/ui-parity.md +++ b/docs/ui-parity.md @@ -1,6 +1,6 @@ # UI parity: tools, handles, modifiers and feel -> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-14 · **Change:** minor (Show Transform Controls frames every selected layer; guide keys and clicks; menu bar from the keyboard, #4988; Crop tool Content-Aware Fill; on-canvas corner radius widgets, TOOL-214-12; Curvature Pen edits the active path; Clip Thumbnails to Layer Bounds; Pen ⇧ 45°; last selected tool restored at launch; Layers panel filter-type dropdown with Kind, Name and Mode, #5048; Free Transform box and marching ants follow the selection, #5297; History: the trash deletes the selected snapshot, rows drag onto the trash, #5324; smart filter reordering by drag; smart object perspective row corrected to done, #426; Free Transform H/V skew fields; toolbar tool shortcuts can be reassigned; ⇧-dragged guides land on ruler ticks; layer and selection keys; tool keys; spring-loaded tool letters; type formatting keys; Pen Auto Add/Delete, #4144; smart filter rows; stale missing-tool rows corrected: Color Replacement, Red Eye and the Add/Delete Anchor Point pair are live toolbar tools, Frame landed; no percentage changed) · **Target:** Adobe Photoshop 2026 +> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-15 · **Change:** minor (Pen Auto Add/Delete on the path being drawn, #6140; Show Transform Controls frames every selected layer; guide keys and clicks; menu bar from the keyboard, #4988; Crop tool Content-Aware Fill; on-canvas corner radius widgets, TOOL-214-12; Curvature Pen edits the active path; Clip Thumbnails to Layer Bounds; Pen ⇧ 45°; last selected tool restored at launch; Layers panel filter-type dropdown with Kind, Name and Mode, #5048; Free Transform box and marching ants follow the selection, #5297; History: the trash deletes the selected snapshot, rows drag onto the trash, #5324; smart filter reordering by drag; smart object perspective row corrected to done, #426; Free Transform H/V skew fields; toolbar tool shortcuts can be reassigned; ⇧-dragged guides land on ruler ticks; layer and selection keys; tool keys; spring-loaded tool letters; type formatting keys; Pen Auto Add/Delete, #4144; smart filter rows; stale missing-tool rows corrected: Color Replacement, Red Eye and the Add/Delete Anchor Point pair are live toolbar tools, Frame landed; no percentage changed) · **Target:** Adobe Photoshop 2026 How PhotoCraft's direct manipulation compares with Photoshop 2026 (27.11.0): the toolbox, the handles and modifier keys of each gesture, nudging, snapping, numeric precision, navigation, @@ -121,7 +121,7 @@ Triangle and Curvature Pen (minus legacy Rounded Rectangle and Targeted Adjustme |---|---|---| | Click corner, drag smooth, ⌥ breaks a handle, ⌘ direct-selects, ⇧ 45° | done | TOOL-214-10; `vector_ui.rs` (#3751) | | Handle on the last point, close-drag handle | partial | #1482 | -| Pen Auto Add/Delete: click a segment of the active path to add, an anchor to delete; ⇧ suspends it | done | #4144 | +| Pen Auto Add/Delete: click a segment of the active path, or of the path being drawn, to add, an anchor to delete; ⇧ suspends it | done | #4144, #6140 | | Curvature Pen edits the active path with no modifier: drag an anchor (the curve reflows), click a segment to add an anchor, double-click smooth ↔ corner; cursor badge + on segments, four arrows on anchors | done: `direct_select.rs` (`curvature_press`, `curvature_hover`), `path.moveAnchors`/`path.convertPoint` with `curvature`, `direct_select_tests.rs` | | | Path operations in the options bar (combine, subtract, intersect, exclude) | partial | `layer.combineShapes.*`; Pen options #1397 | | On-canvas corner-radius handles on live shapes | done: every selected live rectangle shows a widget at each arc centre with Path Selection, the Rectangle tool and Move with Show Transform Controls; a drag rounds every corner of every selected rectangle to the same radius, ⌥ that corner on each, with the corner radius cursor and an `R:` readout, previewed and committed as one history step (`corner_radius.rs`, `corner_radius_tests.rs`) | TOOL-214-12 | @@ -243,6 +243,7 @@ Paragraph; the rest open as in-app floating windows. | 2026-10-11 | minor | Spring-loaded tool letters done (#2672): holding any tool letter (Z included) past 300 ms, or using the canvas while it is down, makes the tool temporary until release | | 2026-10-11 | minor | Type formatting keys done: ⇧⌘K, U, B, I and /, ⌥↑/↓, ⇧⌥↑/↓, ⌥←/→ over a selection and ⌥⇧⌘H format the selected type while editing, and no longer reach Color Settings, Desaturate, Auto Color or Inverse | | 2026-10-12 | minor | Pen Auto Add/Delete on the active path, with its options-bar checkbox, hover feedback and + / − cursor badge (#4144) | +| 2026-10-15 | minor | Pen Auto Add/Delete works on the path being drawn: a click on its segment adds an anchor, on a middle anchor deletes it, and drawing carries on (#6140) | | 2026-10-12 | minor | Toolbar tool shortcuts can be reassigned, removed and reset in Keyboard Shortcuts (#4086); capture, restart, group cycling, conflict and text-focus regressions | | 2026-10-12 | minor | Smart filter rows can be selected, and dragged (⌥: copied) onto another smart object or onto Delete (#4271) | | 2026-10-12 | minor | Free Transform options bar: H and V skew fields; a sheared box reads as skew instead of a taller H | From b24c0360f1839885812f87feabaa102afcce9c3c Mon Sep 17 00:00:00 2001 From: Michael Higgins <55243596+Higgs32584@users.noreply.github.com> Date: Thu, 15 Oct 2026 15:37:56 -0400 Subject: [PATCH 26/47] Dependencies: require ruzstd 0.9.1 for RUSTSEC-2026-0344 (#4952) (#5225) ruzstd before 0.9.1 lets a crafted zstd input under 128 KB allocate several gigabytes at once. PhotoCraft decodes zstd when it opens .pcraft and Affinity files, so such a file could end the process with an out-of-memory abort. Raise the minimum in both crates that use it and update the lockfile from 0.9.0 to 0.9.1. Co-authored-by: Claude Opus 5.5 --- Cargo.lock | 4 ++-- crates/affinity/Cargo.toml | 2 +- crates/format/Cargo.toml | 2 +- 3 files changed, 4 insertions(+), 4 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index b3a268add9..138ca3d814 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -6113,9 +6113,9 @@ dependencies = [ [[package]] name = "ruzstd" -version = "0.9.0" +version = "0.9.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a252f5e20f038fe7b4ea53e073e65398d652c864cc162fc77c56c2f13717b888" +checksum = "69e4ce4a039f42bf25920b95667e4d789e98749ab11bbb8d2c2936f788421bb5" dependencies = [ "twox-hash", ] diff --git a/crates/affinity/Cargo.toml b/crates/affinity/Cargo.toml index e686e737ef..49d859c2f8 100644 --- a/crates/affinity/Cargo.toml +++ b/crates/affinity/Cargo.toml @@ -16,7 +16,7 @@ synth = [] [dependencies] crc32fast = "1.5" flate2 = { workspace = true } -ruzstd = "0.9" +ruzstd = "0.9.1" [dev-dependencies] proptest = { workspace = true } diff --git a/crates/format/Cargo.toml b/crates/format/Cargo.toml index 45d7a4ccf3..113d9ba026 100644 --- a/crates/format/Cargo.toml +++ b/crates/format/Cargo.toml @@ -22,7 +22,7 @@ flate2 = { workspace = true } # Same IEEE CRC-32 the ZIP container needs; already in Cargo.lock through flate2. crc32fast = "1.5" blake3 = { version = "1.8", default-features = false, features = ["pure"] } -ruzstd = "0.9" +ruzstd = "0.9.1" # Unix directory cleanup uses no-follow descriptor-relative operations; the same # dependency provides the plain-fsync fallback on Apple file systems. From 64520f645e073b23960b774a677d54f504094843 Mon Sep 17 00:00:00 2001 From: Michael Higgins <55243596+Higgs32584@users.noreply.github.com> Date: Thu, 15 Oct 2026 15:39:19 -0400 Subject: [PATCH 27/47] Control channel: connections must authenticate within 10 s, however active (#4965) (#5229) The only limit on an unauthenticated control connection was the 30 s idle timeout, which starts again with every read. A local client that kept sending bytes or blank lines without ever completing `auth` kept its connection, and one of the 16 connection permits, for as long as it liked; sixteen of them locked every other client out. AuthDeadlineStream wraps the read half of a connection: until the connection authenticates, reads fail once AUTH_DEADLINE (10 s) has passed since it connected, and a blocking read waits no longer than what is left. Both transports use it (the desktop app's control server and the headless `photocraft-cli serve`). After `auth` succeeds only the idle timeout applies, as before. Co-authored-by: Claude Opus 5.5 --- apps/photocraft/src/control_server.rs | 45 ++++++++++- crates/automation/src/rpc.rs | 69 ++++++++++++++-- crates/automation/src/security.rs | 110 +++++++++++++++++++++++++- 3 files changed, 213 insertions(+), 11 deletions(-) diff --git a/apps/photocraft/src/control_server.rs b/apps/photocraft/src/control_server.rs index 258d89e1ec..30ae610b70 100644 --- a/apps/photocraft/src/control_server.rs +++ b/apps/photocraft/src/control_server.rs @@ -5,11 +5,12 @@ use std::io::{BufReader, Write}; use std::net::{TcpListener, TcpStream}; use std::sync::Arc; use std::sync::mpsc::{Receiver, Sender, channel}; -use std::time::Instant; +use std::time::{Duration, Instant}; use photocraft_automation::budgets::write_reply; use photocraft_automation::security::{ - ConnectionLimiter, LineRead, MAX_CONNECTIONS, MAX_REQUEST_BYTES, authentication_reply, configure_stream, read_bounded_line, repeat_auth_reply, + AUTH_DEADLINE, AuthDeadlineStream, ConnectionLimiter, LineRead, MAX_CONNECTIONS, MAX_REQUEST_BYTES, authentication_reply, configure_stream, + read_bounded_line, repeat_auth_reply, }; use photocraft_ui_egui::ControlRequest; use photocraft_ui_egui::control::REPLY_DEADLINE; @@ -47,11 +48,17 @@ pub fn start(port: u16, token: String, ctx: egui::Context) -> Receiver, ctx: egui::Context) { + serve_with_auth_deadline(stream, token, tx, ctx, AUTH_DEADLINE); +} + +/// [`serve`] for a connection that must complete `auth` within `auth_deadline` of connecting, +/// however much it sends meanwhile (#4965); after that only the idle timeout applies. +fn serve_with_auth_deadline(stream: TcpStream, token: &str, tx: Sender, ctx: egui::Context, auth_deadline: Duration) { if configure_stream(&stream).is_err() { return; } let Ok(read) = stream.try_clone() else { return }; - let mut reader = BufReader::new(read); + let mut reader = BufReader::new(AuthDeadlineStream::new(read, auth_deadline)); let mut out = stream; let mut line = String::new(); let mut authenticated = false; @@ -74,6 +81,9 @@ fn serve(stream: TcpStream, token: &str, tx: Sender, ctx: egui:: if !authenticated { let (reply, ok) = authentication_reply(&line, token); authenticated = ok; + if ok && reader.get_mut().authenticated().is_err() { + break; + } if write_reply(&mut out, &reply).is_err() || out.flush().is_err() || !authenticated { break; } @@ -120,7 +130,34 @@ fn serve(stream: TcpStream, token: &str, tx: Sender, ctx: egui:: mod tests { use super::*; use std::io::BufRead; - use std::time::Duration; + + /// #4965: a connection that keeps sending without authenticating is closed at the deadline + /// (releasing its connection permit), and nothing it sent is dispatched. + #[test] + fn an_unauthenticated_connection_is_closed_at_the_deadline_whatever_it_sends() { + let listener = TcpListener::bind("127.0.0.1:0").unwrap(); + let addr = listener.local_addr().unwrap(); + let (tx, rx) = channel::(); + let server = std::thread::spawn(move || { + let (stream, _) = listener.accept().unwrap(); + let started = Instant::now(); + serve_with_auth_deadline(stream, "unused", tx, egui::Context::default(), Duration::from_millis(300)); + started.elapsed() + }); + // Blank lines and partial frames, one byte every 20 ms: each read succeeds long before + // the idle timeout. + let mut stream = TcpStream::connect(addr).unwrap(); + for i in 0..150 { + let byte: &[u8] = if i % 5 == 0 { b"\n" } else { b" " }; + if stream.write_all(byte).is_err() { + break; + } + std::thread::sleep(Duration::from_millis(20)); + } + let served = server.join().unwrap(); + assert!(served < Duration::from_secs(3), "the trickle did not extend the deadline: {served:?}"); + assert!(rx.try_recv().is_err(), "nothing was dispatched"); + } #[test] fn oversized_reply_preserves_framing_id_and_the_next_control_request() { diff --git a/crates/automation/src/rpc.rs b/crates/automation/src/rpc.rs index dc01f0f2b4..15a0e24e30 100644 --- a/crates/automation/src/rpc.rs +++ b/crates/automation/src/rpc.rs @@ -29,8 +29,8 @@ use serde_json::{Value, json}; use crate::budgets::{BatchReplyBudget, write_reply}; use crate::security::{ - ConnectionLimiter, LineRead, MAX_BATCH_STEPS, MAX_CONNECTIONS, MAX_REQUEST_BYTES, authentication_reply, configure_stream, discard_rest_of_line, - read_bounded_line, repeat_auth_reply, + AUTH_DEADLINE, AuthDeadlineStream, ConnectionLimiter, LineRead, MAX_BATCH_STEPS, MAX_CONNECTIONS, MAX_REQUEST_BYTES, authentication_reply, + configure_stream, discard_rest_of_line, read_bounded_line, repeat_auth_reply, }; use crate::{AutomationError, Headless}; @@ -262,10 +262,12 @@ pub fn serve_lines(h: &Mutex, mut r: impl BufRead, mut w: impl Write) } } -fn serve_tcp_connection(h: &Mutex, stream: std::net::TcpStream, token: &str) -> std::io::Result<()> { +/// Serve one connection. It must complete `auth` within `auth_deadline` of connecting, however +/// much it sends meanwhile (#4965); after that only the idle timeout applies. +fn serve_tcp_connection(h: &Mutex, stream: std::net::TcpStream, token: &str, auth_deadline: std::time::Duration) -> std::io::Result<()> { configure_stream(&stream)?; let read = stream.try_clone()?; - let mut reader = std::io::BufReader::new(read); + let mut reader = std::io::BufReader::new(AuthDeadlineStream::new(read, auth_deadline)); let mut out = stream; let mut line = String::new(); let mut authenticated = false; @@ -290,6 +292,9 @@ fn serve_tcp_connection(h: &Mutex, stream: std::net::TcpStream, token: } else { let (reply, ok) = authentication_reply(&line, token); authenticated = ok; + if ok { + reader.get_mut().authenticated()?; + } reply }; write_reply(&mut out, &reply)?; @@ -322,7 +327,7 @@ pub fn serve_tcp(addr: &str, h: Arc>, token: String, ready: impl let token = Arc::clone(&token); std::thread::spawn(move || { let _permit = permit; - let _ = serve_tcp_connection(&h, stream, &token); + let _ = serve_tcp_connection(&h, stream, &token, AUTH_DEADLINE); }); } Ok(()) @@ -814,6 +819,60 @@ mod tests { assert!(v["result"].as_array().unwrap().iter().any(|m| m == "batch")); } + /// #4965: a connection that keeps sending without authenticating is closed at the deadline, + /// and one that authenticated in time keeps being served after it. + #[test] + fn tcp_connections_must_authenticate_before_the_deadline_whatever_they_send() { + use std::io::{BufReader, Write as _}; + use std::time::{Duration, Instant}; + const TOKEN: &str = "0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef"; + let listener = TcpListener::bind("127.0.0.1:0").unwrap(); + let addr = listener.local_addr().unwrap(); + let server = std::thread::spawn(move || { + let h = Mutex::new(Headless::new()); + let mut results = Vec::new(); + for _ in 0..2 { + let (stream, _) = listener.accept().unwrap(); + let started = Instant::now(); + let r = serve_tcp_connection(&h, stream, TOKEN, Duration::from_millis(300)); + results.push((r.map_err(|e| e.kind()), started.elapsed())); + } + results + }); + + // Never a newline, a byte every 20 ms: each read succeeds long before the idle timeout. + let mut slow = std::net::TcpStream::connect(addr).unwrap(); + for _ in 0..150 { + if slow.write_all(b" ").is_err() { + break; + } + std::thread::sleep(Duration::from_millis(20)); + } + drop(slow); + + // Authenticates at once, then asks again 300 ms after the deadline. + let mut s = std::net::TcpStream::connect(addr).unwrap(); + s.set_read_timeout(Some(Duration::from_secs(5))).unwrap(); + writeln!(s, r#"{{"id":"auth","method":"auth","params":{{"token":"{TOKEN}"}}}}"#).unwrap(); + let mut reader = BufReader::new(s.try_clone().unwrap()); + let mut line = String::new(); + reader.read_line(&mut line).unwrap(); + assert_eq!(serde_json::from_str::(&line).unwrap()["result"]["authenticated"], true); + std::thread::sleep(Duration::from_millis(600)); + writeln!(s, r#"{{"id":"a","method":"methods"}}"#).unwrap(); + line.clear(); + reader.read_line(&mut line).unwrap(); + assert_eq!(serde_json::from_str::(&line).unwrap()["id"], "a"); + drop(reader); + drop(s); + + let results = server.join().unwrap(); + let (slow_result, slow_time) = &results[0]; + assert!(matches!(slow_result, Err(std::io::ErrorKind::TimedOut | std::io::ErrorKind::WouldBlock)), "{slow_result:?}"); + assert!(*slow_time < Duration::from_secs(3), "the trickle did not extend the deadline: {slow_time:?}"); + assert_eq!(results[1].0, Ok(()), "the authenticated connection ended with its client"); + } + #[test] fn tcp_repeat_auth_reports_already_authenticated_and_keeps_serving() { use std::io::{BufReader, Write as _}; diff --git a/crates/automation/src/security.rs b/crates/automation/src/security.rs index 4659a3ff17..7928420b12 100644 --- a/crates/automation/src/security.rs +++ b/crates/automation/src/security.rs @@ -1,11 +1,11 @@ //! Shared security primitives for the loopback JSON control transports. use std::fs::OpenOptions; -use std::io::{BufRead, Write}; +use std::io::{BufRead, Read, Write}; use std::path::{Path, PathBuf}; use std::sync::Arc; use std::sync::atomic::{AtomicUsize, Ordering}; -use std::time::Duration; +use std::time::{Duration, Instant}; use serde_json::{Value, json}; @@ -21,6 +21,10 @@ pub const MAX_CONNECTIONS: usize = 16; pub const MAX_BATCH_STEPS: usize = 256; /// Idle/read and write timeout for loopback TCP connections. pub const IO_TIMEOUT: Duration = Duration::from_secs(30); +/// How long a new TCP connection has to authenticate, however active it is. [`IO_TIMEOUT`] starts +/// again with every read, so a client that kept sending without ever completing `auth` held one +/// of the [`MAX_CONNECTIONS`] permits for as long as it liked (#4965). +pub const AUTH_DEADLINE: Duration = Duration::from_secs(10); const TOKEN_BYTES: usize = 32; const TOKEN_HEX_LEN: usize = TOKEN_BYTES * 2; @@ -242,6 +246,44 @@ pub fn configure_stream(stream: &std::net::TcpStream) -> std::io::Result<()> { stream.set_write_timeout(Some(IO_TIMEOUT)) } +/// The read half of a control connection. Until [`Self::authenticated`] is called, reads fail +/// with `TimedOut` once the authentication deadline has passed, whatever arrived in the meantime; +/// the caller then drops the connection and its [`ConnectionPermit`]. +pub struct AuthDeadlineStream { + stream: std::net::TcpStream, + /// `None` once authenticated. + deadline: Option, +} + +impl AuthDeadlineStream { + /// `stream` must authenticate within `limit` from now (normally [`AUTH_DEADLINE`]). + pub fn new(stream: std::net::TcpStream, limit: Duration) -> Self { + // A limit too large to represent expires at once rather than never. + let now = Instant::now(); + Self { stream, deadline: Some(now.checked_add(limit).unwrap_or(now)) } + } + + /// The connection authenticated: only the idle timeout applies from here on. + pub fn authenticated(&mut self) -> std::io::Result<()> { + self.deadline = None; + self.stream.set_read_timeout(Some(IO_TIMEOUT)) + } +} + +impl Read for AuthDeadlineStream { + fn read(&mut self, buf: &mut [u8]) -> std::io::Result { + if let Some(deadline) = self.deadline { + let left = deadline.saturating_duration_since(Instant::now()); + if left.is_zero() { + return Err(std::io::Error::new(std::io::ErrorKind::TimedOut, "not authenticated in time")); + } + // Wait no longer than what is left, so a silent client is dropped at the deadline too. + self.stream.set_read_timeout(Some(left.min(IO_TIMEOUT)))?; + } + self.stream.read(buf) + } +} + /// Environment-aware token inputs shared by the desktop app and CLI. pub fn token_inputs(supplied: Option, token_file: Option) -> (Option, Option) { let supplied = supplied.or_else(|| std::env::var("PHOTOCRAFT_CONTROL_TOKEN").ok()); @@ -327,6 +369,70 @@ mod tests { std::fs::remove_file(path).unwrap(); } + /// A connected loopback pair: the accepted (server) end and the client end. + fn tcp_pair() -> (std::net::TcpStream, std::net::TcpStream) { + let listener = std::net::TcpListener::bind("127.0.0.1:0").unwrap(); + let client = std::net::TcpStream::connect(listener.local_addr().unwrap()).unwrap(); + let (server, _) = listener.accept().unwrap(); + configure_stream(&server).unwrap(); + (server, client) + } + + /// #4965: activity must not extend the time a connection may stay unauthenticated. + #[test] + fn an_active_connection_is_dropped_at_the_authentication_deadline() { + let (server, mut client) = tcp_pair(); + // A byte every 20 ms and never a newline: each read succeeds long before the idle timeout. + let trickle = std::thread::spawn(move || { + for _ in 0..150 { + if client.write_all(b"x").is_err() { + break; + } + std::thread::sleep(Duration::from_millis(20)); + } + }); + let mut reader = std::io::BufReader::new(AuthDeadlineStream::new(server, Duration::from_millis(300))); + let mut line = String::new(); + let started = Instant::now(); + let err = read_bounded_line(&mut reader, &mut line).unwrap_err(); + assert!(matches!(err.kind(), std::io::ErrorKind::TimedOut | std::io::ErrorKind::WouldBlock), "{err}"); + assert!(started.elapsed() >= Duration::from_millis(250), "not before the deadline: {:?}", started.elapsed()); + assert!(started.elapsed() < Duration::from_secs(3), "the trickle did not extend it: {:?}", started.elapsed()); + // It stays expired. + assert!(read_bounded_line(&mut reader, &mut line).is_err()); + drop(reader); + trickle.join().unwrap(); + } + + #[test] + fn a_silent_connection_is_dropped_at_the_authentication_deadline() { + let (server, _client) = tcp_pair(); + let mut reader = std::io::BufReader::new(AuthDeadlineStream::new(server, Duration::from_millis(200))); + let mut line = String::new(); + let started = Instant::now(); + assert!(read_bounded_line(&mut reader, &mut line).is_err()); + assert!(started.elapsed() < Duration::from_secs(3), "{:?}", started.elapsed()); + } + + #[test] + fn an_authenticated_connection_outlives_the_authentication_deadline() { + let (server, mut client) = tcp_pair(); + let sender = std::thread::spawn(move || { + client.write_all(b"one\n").unwrap(); + std::thread::sleep(Duration::from_millis(500)); + client.write_all(b"two\n").unwrap(); + }); + let mut reader = std::io::BufReader::new(AuthDeadlineStream::new(server, Duration::from_millis(200))); + let mut line = String::new(); + assert_eq!(read_bounded_line(&mut reader, &mut line).unwrap(), LineRead::Line); + assert_eq!(line, "one\n"); + reader.get_mut().authenticated().unwrap(); + // Sent 300 ms after the deadline: only the idle timeout applies now. + assert_eq!(read_bounded_line(&mut reader, &mut line).unwrap(), LineRead::Line); + assert_eq!(line, "two\n"); + sender.join().unwrap(); + } + #[test] fn connection_limiter_releases_capacity() { let limiter = ConnectionLimiter::new(1); From e4b2868a02c99afb30ed5c61022e03b13574e873 Mon Sep 17 00:00:00 2001 From: jonathanwxh-cell Date: Fri, 16 Oct 2026 03:40:50 +0800 Subject: [PATCH 28/47] Check deep EXR tile count tables against max_alloc (#5632) * Check deep EXR tile count tables against max_alloc A deep tile's sample-count table was decompressed at the header's full tile size with no budget check, so a 20 KB file could build a 4 MiB table and a scratch copy under a 1 MiB max_alloc. Try that size only when max_alloc has room for both; otherwise read the clipped size. Fixes #4958. Co-Authored-By: Claude Opus 5.5 * Keep deep EXR decompression within the size it was admitted at The count-table check admits the table and its scratch copy, 2x the table, but Vec doubling gave a 4 MiB ZIPS table 8 MiB of capacity (a 3 MiB RLE table 4 MiB), so an admitted table and its copy could take 3x. Grow the decompressed buffer by doubling capped at the expected size, inflating straight into it, and allocate it and the scratch copy fallibly. Tests: decompressed buffers never reserve past the expected size; a near-maximal tile with a clipped table decodes under every budget; per-tile admission; half and uint channels after full-size tables; the 8 MiB boundary for uncompressed tables too. Refs #4958. Co-Authored-By: Claude Opus 5.5 * Tests: show deep tile tables are admitted one tile at a time The 17x17 image in 16x16 tiles had an interior tile, which never reaches the max_alloc check, so only three edge tables (6 KiB) were checked against the 8000-byte budget and a check that summed admissions across tiles still passed. Use a 48x1 image instead: its three tiles are all edge tiles, and under 4096 bytes a summed check fails at the third tile. Refs #4958. Co-Authored-By: Claude Opus 5.5 * Complete deep EXR count-table allocation admission Account for scratch space on clipped and interior compressed tables while preserving raw-table budgets. Borrow packed input before admission and make initial RLE reservations and raw copies fallible. Add single-channel admission boundaries, raw fallback controls, and hostile-size borrowed-read regressions. * ci: run deep EXR regressions independently * Drop the separate deep EXR CI job It was added so these tests could run while main's workspace suite was failing. Main is green again, and the regular test jobs run them. This reverts commit 5aa4869b45ffd8e89cdd3f02c9aa71d64e217929. Co-Authored-By: Claude Opus 5.5 --------- Co-authored-by: Claude Opus 5.5 Co-authored-by: jonathnwxh <258340642+jonathanwxh-cell@users.noreply.github.com> --- crates/codecs/src/codecs/deep_exr.rs | 195 ++++++++++++++++++++++++--- crates/codecs/tests/deep_exr.rs | 152 ++++++++++++++++++++- 2 files changed, 325 insertions(+), 22 deletions(-) diff --git a/crates/codecs/src/codecs/deep_exr.rs b/crates/codecs/src/codecs/deep_exr.rs index 0726228be5..29f3762ebc 100644 --- a/crates/codecs/src/codecs/deep_exr.rs +++ b/crates/codecs/src/codecs/deep_exr.rs @@ -23,7 +23,7 @@ use half::f16; use crate::Format; use crate::error::CodecError; use crate::image::{ChannelLayout, DecodeWarning, DeepChannel, DeepImage, Image, SampleType}; -use crate::options::Limits; +use crate::options::{Limits, alloc_zeroed}; const F: Format = Format::OpenExr; @@ -39,9 +39,15 @@ fn unsup(reason: impl Into) -> CodecError { // Every length and offset comes from the file, so every read is bounds-checked and a short // read is a malformed file, never a panic. -fn read_exact(cur: &mut Cursor<&[u8]>, n: usize, what: &str) -> Result, CodecError> { - let mut v = vec![0u8; n]; - cur.read_exact(&mut v).map_err(|_| err(format!("truncated file while reading {what}")))?; +fn read_exact<'a>(cur: &mut Cursor<&'a [u8]>, n: usize, what: &str) -> Result<&'a [u8], CodecError> { + // Borrow packed payloads from the input. Copying them here allocates before count-table + // admission and needlessly duplicates the table even when the second pass just skips it. + let truncated = || err(format!("truncated file while reading {what}")); + let start = usize::try_from(cur.position()).map_err(|_| truncated())?; + let end = start.checked_add(n).ok_or_else(truncated)?; + let bytes = *cur.get_ref(); + let v = bytes.get(start..end).ok_or_else(truncated)?; + cur.set_position(end as u64); Ok(v) } @@ -81,10 +87,11 @@ fn differences_to_samples(buf: &mut [u8]) { } /// Undo the ZIP half-split: the stream is `[first half][second half]` with the second half -/// holding every second byte of the original; weave them back together. -fn interleave_byte_blocks(buf: &mut [u8]) { +/// holding every second byte of the original; weave them back together. The scratch copy is +/// allocated fallibly, so a buffer that passed `max_alloc` but not the allocator is an error. +fn interleave_byte_blocks(buf: &mut [u8]) -> Result<(), CodecError> { let half = buf.len().div_ceil(2); - let mut out = vec![0u8; buf.len()]; + let mut out = alloc_zeroed::(buf.len())?; let mut k = 0usize; for i in 0..buf.len() / 2 { let (a, b) = (buf.get(i).copied(), buf.get(half + i).copied()); @@ -105,12 +112,29 @@ fn interleave_byte_blocks(buf: &mut [u8]) { } } buf.copy_from_slice(&out); + Ok(()) +} + +/// Make room in `out` for `extra` more bytes, `limit` in all. Grows by doubling like `Vec`, +/// but never reserves past `limit`: callers admit exactly that size against `max_alloc`, and +/// plain doubling reserved up to twice as much. Fallible, so a failed allocation is an error. +fn reserve_up_to(out: &mut Vec, extra: usize, limit: usize, what: &str) -> Result<(), CodecError> { + let need = out.len().saturating_add(extra); + if need > limit { + return Err(err(format!("{what}: data expands past {limit} bytes"))); + } + if need > out.capacity() { + let target = out.capacity().saturating_mul(2).max(need).min(limit); + out.try_reserve_exact(target.saturating_sub(out.len())).map_err(|_| CodecError::LimitExceeded(format!("cannot allocate {target} bytes for {what}")))?; + } + Ok(()) } /// OpenEXR RLE tokens: a negative byte `n` means `-n` literal bytes follow, a non-negative /// byte `n` means the next byte repeats `n + 1` times (as `ImfRle.cpp` and the `exr` crate). fn rle_decode(src: &[u8], expected: usize, what: &str) -> Result, CodecError> { - let mut out = Vec::with_capacity(expected.min(1 << 20)); + let mut out = Vec::new(); + reserve_up_to(&mut out, expected.min(1 << 20), expected, what)?; let mut i = 0; while i < src.len() { let count = src[i] as i8; @@ -119,6 +143,7 @@ fn rle_decode(src: &[u8], expected: usize, what: &str) -> Result, CodecE if out.len().saturating_add(n) > expected { return Err(err(format!("{what}: RLE data expands past {expected} bytes"))); } + reserve_up_to(&mut out, n, expected, what)?; if count < 0 { let end = i.saturating_add(n); let run = src.get(i..end.min(src.len())).ok_or_else(|| err(format!("{what}: RLE run points past the compressed data")))?; @@ -143,7 +168,10 @@ fn rle_decode(src: &[u8], expected: usize, what: &str) -> Result, CodecE /// has the uncompressed length is stored raw whatever the declared compression. fn decompress(compression: Compression, packed: &[u8], expected: usize, what: &str) -> Result, CodecError> { if packed.len() == expected { - return Ok(packed.to_vec()); + let mut out = Vec::new(); + reserve_up_to(&mut out, expected, expected, what)?; + out.extend_from_slice(packed); + return Ok(out); } match compression { Compression::Uncompressed => Err(err(format!("{what}: expected {expected} uncompressed bytes, found {}", packed.len()))), @@ -151,20 +179,38 @@ fn decompress(compression: Compression, packed: &[u8], expected: usize, what: &s // Like ZIPS, RLE runs over the predicted, byte-split stream. let mut out = rle_decode(packed, expected, what)?; differences_to_samples(&mut out); - interleave_byte_blocks(&mut out); + interleave_byte_blocks(&mut out)?; Ok(out) } Compression::ZIP1 => { - let mut out = Vec::with_capacity(expected.min(1 << 20)); - flate2::read::ZlibDecoder::new(packed) - .take(expected as u64 + 1) - .read_to_end(&mut out) - .map_err(|_| err(format!("{what}: zlib data is malformed")))?; + // Inflate straight into `out`, grown with `reserve_up_to` rather than by + // `read_to_end`, whose doubling reserves past `expected`. Once `expected` bytes are + // in, a one-byte read checks that the stream ends there. + let malformed = || err(format!("{what}: zlib data is malformed")); + let mut zlib = flate2::read::ZlibDecoder::new(packed); + let mut out = Vec::new(); + let mut filled = 0; + while filled < expected { + if filled == out.len() { + let grow = out.len().max(1 << 13).min(expected - filled); + reserve_up_to(&mut out, grow, expected, what)?; + out.resize(filled + grow, 0); + } + let n = zlib.read(out.get_mut(filled..).unwrap_or_default()).map_err(|_| malformed())?; + if n == 0 { + break; + } + filled += n; + } + out.truncate(filled); + if filled == expected && zlib.read(&mut [0u8; 1]).map_err(|_| malformed())? != 0 { + return Err(err(format!("{what}: zlib data expands past {expected} bytes"))); + } if out.len() != expected { return Err(err(format!("{what}: zlib data expands to {} bytes, expected {expected}", out.len()))); } differences_to_samples(&mut out); - interleave_byte_blocks(&mut out); + interleave_byte_blocks(&mut out)?; Ok(out) } other => Err(unsup(format!("deep data with {other:?} compression (NONE, RLE and ZIPS are supported)"))), @@ -340,13 +386,41 @@ pub(crate) fn decode_deep(meta: &MetaData, bytes: &[u8], limits: &Limits) -> Res if packed_table > remaining || packed_data > remaining.saturating_sub(packed_table) { return Err(err("deep chunk sizes point past the end of the file")); } - let table_bytes = read_exact(&mut cur, packed_table as usize, "the sample-count table")?; + let what = "the sample-count table"; + let table_bytes = read_exact(&mut cur, packed_table as usize, what)?; let expected_table = block_pixels.len().checked_mul(4).ok_or_else(|| err("count table overflows"))?; + // A compressed clipped table also needs its scratch copy. The data-window check only + // budgets four bytes per channel per pixel, insufficient for a one-channel image. + // An equal-sized payload takes decompress's raw path, which allocates no scratch. + let buffers = if table_bytes.len() == expected_table { 1 } else { 2 }; + let clipped_need = (expected_table as u64).saturating_mul(buffers); + if clipped_need > limits.max_alloc { + return Err(CodecError::LimitExceeded(format!( + "{what} needs {clipped_need} bytes including decompression scratch, exceeding max_alloc {}", + limits.max_alloc + ))); + } // An edge tile's table may still be stored at the full tile size (its pixels first, in // rows of the clipped width, then unused entries), as the OpenEXR library writes it. - let full_table = tile_w.saturating_mul(tile_h).saturating_mul(4).max(expected_table); - let raw = decompress(compression, &table_bytes, full_table, "the sample-count table") - .or_else(|_| decompress(compression, &table_bytes, expected_table, "the sample-count table"))?; + // That size comes from the header's tile size alone, which nothing ties to the data + // window, so it is tried only when `max_alloc` has room for the table and for the copy + // that undoes its byte split. Otherwise only the clipped size is read. + let full_table = tile_w.checked_mul(tile_h).and_then(|n| n.checked_mul(4)); + let raw = match full_table { + // Scan lines and interior tiles: the full size is the clipped size. + Some(full) if full <= expected_table => decompress(compression, table_bytes, expected_table, what)?, + Some(full) if (full as u64).saturating_mul(2) <= limits.max_alloc => { + decompress(compression, table_bytes, full, what).or_else(|_| decompress(compression, table_bytes, expected_table, what))? + } + _ => decompress(compression, table_bytes, expected_table, what).map_err(|_| { + // Four bytes per pixel of the tile, twice: the table and its scratch copy. + let need = (tile_w as u64).saturating_mul(tile_h as u64).saturating_mul(8); + CodecError::LimitExceeded(format!( + "{what} of a {tile_w}x{tile_h} deep tile does not decode at the clipped size, and at the full tile size it needs {need} bytes with its scratch copy, exceeding max_alloc {}", + limits.max_alloc + )) + })?, + }; // The table is cumulative within each row of the block (a tile restarts at every row); // `prefix` is cumulative over the whole block, and each pixel's own count is the difference. @@ -423,7 +497,7 @@ pub(crate) fn decode_deep(meta: &MetaData, bytes: &[u8], limits: &Limits) -> Res // The count table sits between the sizes and the sample data; skip its bytes. read_exact(&mut cur, packed_table as usize, "the sample-count table")?; let data_bytes = read_exact(&mut cur, chunk.packed_data as usize, "the deep sample data")?; - let raw = decompress(compression, &data_bytes, chunk.unpacked_data as usize, "the deep sample data")?; + let raw = decompress(compression, data_bytes, chunk.unpacked_data as usize, "the deep sample data")?; // Row by row (a scan line block is one row), and within a row channel by channel, as // flat tiles are laid out: each pixel's samples are contiguous within its channel. let mut in_off = 0usize; @@ -611,3 +685,82 @@ pub(crate) fn flatten(deep: &DeepImage) -> Result { img.warnings.push(DecodeWarning::DeepFlattened { format: F, max_samples_per_pixel: max_samples }); Ok(img) } + +#[cfg(test)] +mod tests { + use super::*; + use std::io::Write as _; + + #[test] + fn packed_reads_borrow_input_and_reject_bad_sizes_before_allocation() { + let bytes = [1u8, 2, 3, 4]; + let mut cur = Cursor::new(bytes.as_slice()); + let prefix = read_exact(&mut cur, 3, "x").expect("three input bytes"); + assert_eq!(prefix, &[1, 2, 3]); + assert_eq!(prefix.as_ptr(), bytes.as_ptr()); + assert_eq!(cur.position(), 3); + assert!(matches!(read_exact(&mut cur, usize::MAX, "x"), Err(CodecError::Malformed { .. }))); + assert_eq!(cur.position(), 3); + assert_eq!(read_exact(&mut cur, 1, "x").expect("last byte"), &[4]); + assert!(matches!(read_exact(&mut cur, 1, "x"), Err(CodecError::Malformed { .. }))); + cur.set_position(u64::MAX); + assert!(matches!(read_exact(&mut cur, 1, "x"), Err(CodecError::Malformed { .. }))); + } + + /// What the writer stores before compressing: the byte split, then the +128 predictor. + fn predicted(raw: &[u8]) -> Vec { + let half = raw.len().div_ceil(2); + let mut out = vec![0u8; raw.len()]; + for (i, &v) in raw.iter().enumerate() { + out[if i % 2 == 0 { i / 2 } else { half + i / 2 }] = v; + } + let mut prev = 0i16; + for (i, b) in out.iter_mut().enumerate() { + let v = i16::from(*b); + if i > 0 { + *b = (v - prev + 128) as u8; + } + prev = v; + } + out + } + + /// #4958: callers admit `expected` bytes for a decompressed buffer (twice that for a count + /// table, with the copy that undoes its byte split), so decompression must not reserve + /// more. `Vec`'s doubling gave a 4 MiB ZIPS table 8 MiB of capacity and a 3 MiB RLE table + /// 4 MiB, so the "admitted" table and its copy took up to three times the table size. + #[test] + fn decompressed_buffers_reserve_no_more_than_expected() { + for n in [1usize << 20, 2 << 20, (3 << 20) + 1, 4 << 20, 5 << 20] { + let raw = vec![0u8; n]; + let p = predicted(&raw); + let mut z = flate2::write::ZlibEncoder::new(Vec::new(), flate2::Compression::fast()); + z.write_all(&p).expect("write to a Vec"); + let zips = z.finish().expect("finish a Vec"); + // RLE: the first byte as a literal, then runs of up to 128 copies of the next. + let mut rle = vec![0xFF, p[0]]; + for run in p[1..].chunks(128) { + rle.extend_from_slice(&[(run.len() - 1) as u8, run[0]]); + } + for (compression, packed) in [(Compression::ZIP1, &zips), (Compression::RLE, &rle)] { + let out = decompress(compression, packed, n, "the table").expect("decompresses"); + assert_eq!(out, raw, "{compression:?}, {n} bytes"); + assert!(out.capacity() <= n, "{compression:?}: a {n} byte buffer reserved {} bytes", out.capacity()); + } + // One byte short, the same data expands past the expected size and is refused. + for (compression, packed) in [(Compression::ZIP1, &zips), (Compression::RLE, &rle)] { + assert!(decompress(compression, packed, n - 1, "the table").is_err(), "{compression:?}, {n} bytes"); + } + } + } + + /// A size past the limit is malformed data, and a reservation the allocator refuses is an + /// error rather than an abort. + #[test] + fn reservations_past_the_limit_or_the_allocator_are_errors() { + let mut v = Vec::new(); + assert!(matches!(reserve_up_to(&mut v, 2, 1, "x"), Err(CodecError::Malformed { .. }))); + assert!(matches!(reserve_up_to(&mut v, isize::MAX as usize + 1, usize::MAX, "x"), Err(CodecError::LimitExceeded(_)))); + assert_eq!(v.capacity(), 0); + } +} diff --git a/crates/codecs/tests/deep_exr.rs b/crates/codecs/tests/deep_exr.rs index 5a6d6ae75e..f80a42f17b 100644 --- a/crates/codecs/tests/deep_exr.rs +++ b/crates/codecs/tests/deep_exr.rs @@ -118,6 +118,23 @@ fn gen_deep( tile: Option<(usize, usize)>, counts: &[u32], sample: &dyn Fn(usize, usize) -> Vec, +) -> Vec { + gen_deep_tables(w, h, channels, compression, tile, counts, sample, false) +} + +/// [`gen_deep`]; with `full_tile_tables` every tile's count table is stored at the full tile +/// size, as the OpenEXR library writes edge tiles: the block's entries first (in rows of its +/// clipped width), then zeros. +#[allow(clippy::too_many_arguments)] +fn gen_deep_tables( + w: usize, + h: usize, + channels: &[Chan], + compression: u8, + tile: Option<(usize, usize)>, + counts: &[u32], + sample: &dyn Fn(usize, usize) -> Vec, + full_tile_tables: bool, ) -> Vec { let mut out = Vec::new(); out.extend_from_slice(&0x0131_2f76u32.to_le_bytes()); @@ -216,6 +233,10 @@ fn gen_deep( table.extend_from_slice(&acc.to_le_bytes()); } } + assert_eq!(table.len(), table_len(b)); + if let (true, Some((tw, th))) = (full_tile_tables, tile) { + table.resize(tw * th * 4, 0); + } let mut data = Vec::new(); for row in b.pixels.chunks(b.row_w) { for (ci, ch) in channels.iter().enumerate() { @@ -237,7 +258,6 @@ fn gen_deep( c.extend_from_slice(&(data.len() as u64).to_le_bytes()); c.extend_from_slice(&ct); c.extend_from_slice(&cd); - assert_eq!(table.len(), table_len(b)); assert_eq!(data.len(), data_len(b)); chunks.push((bi, c)); } @@ -608,6 +628,136 @@ fn hostile_sizes_and_offsets_error() { assert!(matches!(decode_deep_exr(&far, &Limits::none()), Err(CodecError::Malformed { .. }))); } +/// #4958: a deep tile's count table may be stored at the full tile size, and that size comes +/// from the header alone: nothing ties it to the data window. One 1024×1024 tile over a 4×4 +/// image, its table stored at that size, makes a compressed file of tens of KB whose table +/// inflates to 4 MiB, plus as much again for the copy that undoes the byte split. Under a +/// budget smaller than both the table must be refused before it is allocated; with room for +/// both it decodes. Tables stored at the clipped size, and the full-size edge tables of +/// ordinary tiles, still decode under a small budget. +#[test] +fn deep_tile_count_table_is_admitted_against_max_alloc() { + let budget = |max_alloc: u64| Limits { max_alloc, ..Limits::none() }; + let tight = budget(1 << 20); + let table: u64 = 1024 * 1024 * 4; // the full tile's count table, in bytes + let scan = decode_deep_exr(&gen_deep(4, 4, &RGBAZ, 0, None, COUNTS_ONE, &sample_rgba), &Limits::none()).expect("scan lines"); + for compression in [0u8, 1, 2] { + let big = gen_deep_tables(4, 4, &RGBAZ, compression, Some((1024, 1024)), COUNTS_ONE, &sample_rgba, true); + assert_eq!(decode_deep_exr(&big, &Limits::none()).expect("full-size table, no limits"), scan, "compression {compression}"); + let got = decode_deep_exr(&big, &tight); + assert!( + matches!(got, Err(CodecError::LimitExceeded(_))), + "compression {compression}: a 4 MiB count table under a 1 MiB budget gave {:?}", + got.map(|d| d.total_samples()) + ); + let flat = photocraft_codecs::decode_with(&big, &photocraft_codecs::DecodeOptions { limits: tight, ..Default::default() }); + assert!(matches!(flat, Err(CodecError::LimitExceeded(_))), "compression {compression}: the flat decode gave {:?}", flat.map(|i| i.width())); + // The same tile with its table stored at the clipped size fits the small budget. + let clipped = gen_deep(4, 4, &RGBAZ, compression, Some((1024, 1024)), COUNTS_ONE, &sample_rgba); + assert_eq!(decode_deep_exr(&clipped, &tight).expect("clipped table, 1 MiB budget"), scan, "compression {compression}"); + if compression != 0 { + assert!(big.len() < 1 << 20, "compression {compression}: the file is {} bytes", big.len()); + } + // The table and its scratch copy are admitted together: 8 MiB. + assert_eq!(decode_deep_exr(&big, &budget(2 * table)).expect("room for the table and its copy"), scan, "compression {compression}"); + let got = decode_deep_exr(&big, &budget(2 * table - 1)); + assert!(matches!(got, Err(CodecError::LimitExceeded(_))), "compression {compression}: one byte short gave {:?}", got.map(|d| d.total_samples())); + } + // Ordinary tiles: full-size edge tables decode under the small budget, like clipped ones. + let counts: Vec = (0..35).map(|p| (p * 3) % 4).collect(); + let sample = |p: usize, ci: usize| -> Vec { (0..counts[p]).map(|k| [0.5, 0.25, 0.125 * k as f32, 1.0, 10.0 - k as f32][ci]).collect() }; + for compression in [0u8, 1, 2] { + let full = gen_deep_tables(7, 5, &RGBAZ, compression, Some((3, 2)), &counts, &sample, true); + let clipped = gen_deep(7, 5, &RGBAZ, compression, Some((3, 2)), &counts, &sample); + assert_eq!( + decode_deep_exr(&full, &tight).expect("full-size edge tables"), + decode_deep_exr(&clipped, &tight).expect("clipped edge tables"), + "compression {compression}" + ); + } +} + +/// #4958, the edges of that admission. A tile of the largest size exr accepts (2^30 - 2 a side, +/// a 2^62 byte count table) decodes from a clipped table under every budget, including no limits, +/// where its full size passes the check and is only tried as the data arrives. Each tile is +/// admitted on its own, because its table is freed before the next chunk: three 16×16 tiles over +/// a 48×1 image are all edge tiles, so every full-size table reaches the check (an interior tile's +/// full size equals its clipped size, so it uses the clipped check). They need 2 KiB each for +/// the table and its copy, 6 KiB in all, and decode under a budget of 4096 bytes. Half and uint +/// channels decode after full-size tables like float ones. +#[test] +fn deep_tile_table_admission_edges() { + let budget = |max_alloc: u64| Limits { max_alloc, ..Limits::none() }; + let scan = decode_deep_exr(&gen_deep(4, 4, &RGBAZ, 0, None, COUNTS_ONE, &sample_rgba), &Limits::none()).expect("scan lines"); + let side = (1 << 30) - 2; + for compression in [0u8, 1, 2] { + let bytes = gen_deep(4, 4, &RGBAZ, compression, Some((side, side)), COUNTS_ONE, &sample_rgba); + for limits in [Limits::none(), Limits::default(), budget(1 << 20)] { + assert_eq!(decode_deep_exr(&bytes, &limits).expect("the largest tile, clipped table"), scan, "compression {compression}, {limits:?}"); + } + } + let counts: Vec = (0..48).map(|p| (p % 3) as u32).collect(); + let half: [Chan; 5] = RGBAZ.map(|c| Chan { ty: 1, ..c }); + let [a, b, g, r, z] = RGBAZ; + let with_id = [a, b, g, r, z, Chan { name: "id", ty: 0 }]; + for channels in [&RGBAZ[..], &half[..], &with_id[..]] { + let sample = |p: usize, ci: usize| -> Vec { (0..counts[p]).map(|k| [0.5, 0.25, 0.125, 1.0, 2.0 + k as f32, 7.0][ci]).collect() }; + for compression in [0u8, 1, 2] { + let full = gen_deep_tables(48, 1, channels, compression, Some((16, 16)), &counts, &sample, true); + let clipped = gen_deep(48, 1, channels, compression, Some((16, 16)), &counts, &sample); + let what = format!("{} channels, compression {compression}", channels.len()); + let want = decode_deep_exr(&clipped, &Limits::none()).expect("clipped tables"); + assert_eq!(decode_deep_exr(&full, &budget(4096)).expect("full-size tables, admitted one at a time"), want, "{what}"); + } + } +} + +/// A one-channel data-window check admits only four bytes per pixel, but a compressed count +/// table and its scratch copy need eight. Cover scanlines, interior tiles and the clipped fallback; +/// the existing multichannel cases have enough pixel allowance to hide this gap. +#[test] +fn clipped_count_tables_include_scratch_in_the_budget() { + let channels = [Chan { name: "Y", ty: 2 }]; + let counts = vec![0u32; 64]; + let sample = |_: usize, _: usize| Vec::new(); + let budget = |max_alloc: u64| Limits { max_alloc, ..Limits::none() }; + for tile in [None, Some((64, 1)), Some((128, 2))] { + for compression in [0u8, 1, 2] { + let bytes = gen_deep(64, 1, &channels, compression, tile, &counts, &sample); + let what = format!("tile {tile:?}, compression {compression}"); + let want = decode_deep_exr(&bytes, &Limits::none()).expect("unlimited decode"); + let required = if compression == 0 { 256 } else { 512 }; + assert_eq!(decode_deep_exr(&bytes, &budget(required)).expect("table and any scratch fit exactly"), want, "{what}"); + assert!(matches!(decode_deep_exr(&bytes, &budget(required - 1)), Err(CodecError::LimitExceeded(_))), "{what}"); + let flat = photocraft_codecs::decode_with(&bytes, &photocraft_codecs::DecodeOptions { limits: budget(required - 1), ..Default::default() }); + assert!(matches!(flat, Err(CodecError::LimitExceeded(_))), "{what}"); + if compression == 0 { + assert_eq!(decode_deep_exr(&bytes, &budget(511)).expect("raw table needs no scratch"), want, "{what}"); + } + } + } +} + +/// RLE and ZIPS store incompressible tables raw too. Their actual payload, not the header's +/// compression tag, decides whether a scratch buffer is needed. +#[test] +fn raw_count_tables_need_no_scratch_even_with_compression() { + let channels = [Chan { name: "Y", ty: 2 }]; + let limits = Limits { max_alloc: 4, ..Limits::none() }; + for tile in [None, Some((1, 1)), Some((2, 2))] { + for compression in [0u8, 1, 2] { + let bytes = gen_deep(1, 1, &channels, compression, tile, &[0], &|_, _| Vec::new()); + let header_end = header_end_of(&bytes); + let chunk = u64::from_le_bytes(bytes[header_end..header_end + 8].try_into().expect("chunk offset")) as usize; + let table_size = chunk + if tile.is_some() { 16 } else { 4 }; + let packed = u64::from_le_bytes(bytes[table_size..table_size + 8].try_into().expect("table size")); + assert_eq!(packed, 4, "the four-byte count table is stored raw"); + let want = decode_deep_exr(&bytes, &Limits::none()).expect("unlimited decode"); + assert_eq!(decode_deep_exr(&bytes, &limits).expect("raw table fits"), want, "tile {tile:?}, compression {compression}"); + } + } +} + /// Walk the attributes like the format prescribes: the offset table starts after the /// header's terminating zero byte. fn header_end_of(bytes: &[u8]) -> usize { From 77a258b9a0c0a165a7ccca414883e18415ef7323 Mon Sep 17 00:00:00 2001 From: Tanish Manku Date: Thu, 15 Oct 2026 13:10:22 -0700 Subject: [PATCH 29/47] Clip groups: Release Clipping Mask takes the layers above; merges keep clipping; Delete Hidden settles (#5868) * Clip groups: Release Clipping Mask takes the layers above; merges keep clipping; Delete Hidden settles From the layers audit (L2, silently wrong results): - Release Clipping Mask on a clipped layer also releases the clipped layers above it, as Adobe documents ("removes the selected layer and any layers above it from the clipping mask"). Before, they stayed clipped to the released layer. - Merge Down of two clipped layers, and Merge Layers of clipped layers of one chain, give a layer still clipped to their base (it was always unclipped). Merge Down's result also keeps the lower layer's colour label and link; Merge Layers' keeps the top layer's label. - Delete Hidden Layers releases the layers clipped to a deleted hidden base (clip_group rule 3, as single Delete already does), instead of leaving them clipped to the layer below. * Merge Layers keeps the result clipped only when the clipped layers share one clip chain * fmt --- crates/engine/src/clip_group.rs | 13 ++++ crates/engine/src/commands.rs | 19 +++++- crates/engine/src/extra_cmds.rs | 5 ++ crates/engine/src/layer_multi_cmds.rs | 6 ++ crates/engine/src/pixels.rs | 5 ++ crates/engine/tests/clipping_semantics.rs | 75 +++++++++++++++++++++++ 6 files changed, 122 insertions(+), 1 deletion(-) create mode 100644 crates/engine/tests/clipping_semantics.rs diff --git a/crates/engine/src/clip_group.rs b/crates/engine/src/clip_group.rs index 4e356efce9..31f62696a3 100644 --- a/crates/engine/src/clip_group.rs +++ b/crates/engine/src/clip_group.rs @@ -42,6 +42,11 @@ fn group_of(doc: &Document, id: LayerId) -> Vec { if group.len() > 1 { group } else { Vec::new() } } +/// The base `id` clips to (itself when it is a base), when it is in a clip group. +pub(crate) fn base_of(doc: &Document, id: LayerId) -> Option { + group_of(doc, id).first().copied() +} + /// The layers clipped to `id`: the clipped run directly above it. Empty when `id` is itself /// clipped (it is not a base) or nothing is clipped to it. fn clipped_to(doc: &Document, id: LayerId) -> Vec { @@ -51,6 +56,14 @@ fn clipped_to(doc: &Document, id: LayerId) -> Vec { } } +/// The clipped layers directly above `id` among its siblings (Release Clipping Mask releases them +/// with it). +pub(crate) fn clipped_above(doc: &Document, id: LayerId) -> Vec { + let Some(path) = doc.path_of(id) else { return Vec::new() }; + let Some((list, idx)) = siblings(doc, &path) else { return Vec::new() }; + list.iter().skip(idx.saturating_add(1)).take_while(|l| l.clipped).map(|l| l.id).collect() +} + /// The clip groups of layers about to move or be deleted, read before they leave their slots. #[derive(Default)] pub(crate) struct Before { diff --git a/crates/engine/src/commands.rs b/crates/engine/src/commands.rs index a05c5b060c..edb60b06dc 100644 --- a/crates/engine/src/commands.rs +++ b/crates/engine/src/commands.rs @@ -253,7 +253,10 @@ pub(crate) fn parse_hex(s: &str) -> Option<[f32; 4]> { } /// Layer › Create / Release Clipping Mask. With a `layer` param, just that layer. Otherwise, as in /// Photoshop, the selection (#1248): Create clips every selected layer except the lowest, which -/// becomes the base; Release releases every selected layer. One history step either way. +/// becomes the base; Release releases every selected layer. Releasing a clipped layer also +/// releases the clipped layers above it in its group, as Adobe documents ("removes the selected +/// layer and any layers above it from the clipping mask"), so none is left clipped to a layer +/// that is no longer clipped. One history step either way. fn set_clipped(s: &mut Session, p: &Value, clip: bool) -> Result { let label = if clip { "Create Clipping Mask" } else { "Release Clipping Mask" }; let ids: Vec = if p.get("layer").is_some() { @@ -267,6 +270,20 @@ fn set_clipped(s: &mut Session, p: &Value, clip: bool) -> Result { (false, all) => all.to_vec(), } }; + let ids = if clip { + ids + } else { + let doc = &s.active().ok_or(EngineError::NoDocument)?.doc; + let mut all = ids.clone(); + for id in ids.iter().filter(|id| doc.layer(**id).is_some_and(|l| l.clipped)) { + for above in crate::clip_group::clipped_above(doc, *id) { + if !all.contains(&above) { + all.push(above); + } + } + } + all + }; s.edit(label, |doc, _| { for id in &ids { doc.layer_mut(*id).ok_or(EngineError::NoLayer(*id))?.clipped = clip; diff --git a/crates/engine/src/extra_cmds.rs b/crates/engine/src/extra_cmds.rs index 3a27c99be3..65d181b955 100644 --- a/crates/engine/src/extra_cmds.rs +++ b/crates/engine/src/extra_cmds.rs @@ -522,11 +522,16 @@ fn delete_where(s: &mut Session, label: &str, pred: fn(&Layer) -> bool) -> Resul n } let backup = doc.layers.clone(); + // The clip groups of the doomed layers, read before they go (clip_group rule 3: layers + // clipped to a deleted base are released). + let doomed: Vec = doc.walk().into_iter().filter(|(_, _, l)| pred(l)).map(|(_, _, l)| l.id).collect(); + let before = crate::clip_group::Before::read(doc, &doomed); let n = rec(&mut doc.layers, pred); if doc.layers.is_empty() { doc.layers = backup; return Err(EngineError::Other("a document must keep at least one layer".into())); } + crate::clip_group::settle(doc, &[], &before, false); if active.is_none_or(|id| doc.layer(id).is_none()) { *active = doc.top_layer(); } diff --git a/crates/engine/src/layer_multi_cmds.rs b/crates/engine/src/layer_multi_cmds.rs index 9a79a72050..bfbd036ec3 100644 --- a/crates/engine/src/layer_multi_cmds.rs +++ b/crates/engine/src/layer_multi_cmds.rs @@ -1312,6 +1312,12 @@ fn merge_layers(s: &mut Session) -> Result { let fmt = PixelFormat::new(fmt.mode, fmt.sample, true); let mut merged = Layer::raster(top.name.clone(), fmt); merged.locks = top.locks; + merged.label = top.label; + // Clipped layers of one chain merge into a layer still clipped to their base. Clipped + // layers of different chains don't: the result, at the top one's slot, would clip the + // lower chain's pixels to the upper chain's base. + let base = crate::clip_group::base_of(doc, top_id); + merged.clipped = base.is_some() && solo.layers.iter().all(|l| l.clipped && crate::clip_group::base_of(doc, l.id) == base); *crate::pixels_mut(&mut merged)? = crate::pixels::composite_layers_uncropped(&solo, fmt); let mid = merged.id; // Hidden selected layers are discarded, as in Photoshop. diff --git a/crates/engine/src/pixels.rs b/crates/engine/src/pixels.rs index e0cea4f39e..b046de03bd 100644 --- a/crates/engine/src/pixels.rs +++ b/crates/engine/src/pixels.rs @@ -415,5 +415,10 @@ pub fn merge_down(doc: &Document, lower: &Layer, upper: &Layer) -> Layer { // Merge Down replaces the lower layer, so keep its editing restrictions. // In particular, a merged Background must remain opaque and position-locked. merged.locks = lower.locks; + // And its place: two layers clipped in one chain merge into a layer still clipped to the base, + // with the lower layer's colour label and link. + merged.clipped = lower.clipped && upper.clipped; + merged.label = lower.label; + merged.link_group = lower.link_group; merged } diff --git a/crates/engine/tests/clipping_semantics.rs b/crates/engine/tests/clipping_semantics.rs new file mode 100644 index 0000000000..1a4aa562a7 --- /dev/null +++ b/crates/engine/tests/clipping_semantics.rs @@ -0,0 +1,75 @@ +//! Clip groups through Release Clipping Mask, Merge Down, Merge Layers and Delete Hidden Layers, +//! as in Photoshop (layers audit L2). The stack: Background, B, C1*, C2* (C1 and C2 clipped to B). + +use photocraft_engine::Session; +use serde_json::json; + +fn stack() -> (Session, [u64; 3]) { + let mut s = Session::new(); + s.execute("file.new", json!({"width": 32, "height": 32, "background": "white"})).unwrap(); + let mut ids = [0u64; 3]; + for (i, color) in ["#ff0000", "#00ff00", "#0000ff"].iter().enumerate() { + ids[i] = s.execute("layer.new.layer", json!({})).unwrap()["layer"].as_u64().unwrap(); + s.execute("select.rect", json!({"x": 4 * i, "y": 4, "width": 12, "height": 12})).unwrap(); + s.execute("edit.fill", json!({"color": color})).unwrap(); + s.execute("select.deselect", json!({})).unwrap(); + } + for c in &ids[1..] { + s.execute("layer.setProps", json!({"layer": c, "clipped": true})).unwrap(); + } + (s, ids) +} + +fn clipped(s: &Session, id: u64) -> bool { + s.active().unwrap().doc.layer(photocraft_doc::LayerId(id)).unwrap().clipped +} + +#[test] +fn releasing_a_middle_layer_releases_the_ones_above_it() { + let (mut s, [_, c1, c2]) = stack(); + let r = s.execute("layer.releaseClippingMask", json!({"layer": c1})).unwrap(); + assert!(!clipped(&s, c1) && !clipped(&s, c2), "C2 isn't left clipped to the released C1: {r}"); + // Releasing the top one leaves the one below it alone. + let (mut s, [_, c1, c2]) = stack(); + s.execute("layer.releaseClippingMask", json!({"layer": c2})).unwrap(); + assert!(clipped(&s, c1) && !clipped(&s, c2)); +} + +#[test] +fn merging_clipped_layers_keeps_the_result_clipped() { + let (mut s, [_, c1, c2]) = stack(); + s.execute("layer.select", json!({"layer": c2})).unwrap(); + let r = s.execute("layer.mergeDown", json!({})).unwrap(); + let merged = r["layer"].as_u64().unwrap_or(c1); + assert!(clipped(&s, merged), "C2 merged down into C1 stays clipped to B"); + let (mut s, [_, c1, c2]) = stack(); + s.execute("layer.select", json!({"layer": c1})).unwrap(); + s.execute("layer.select", json!({"layer": c2, "mode": "add"})).unwrap(); + let r = s.execute("layer.mergeLayers", json!({})).unwrap(); + assert!(clipped(&s, r["layer"].as_u64().unwrap()), "C1 + C2 merged stay clipped"); + // Clipped layers of two chains (Background, B, C1*, E, D*: C1 clipped to B, D to E) merge + // into an ordinary layer: kept clipped at D's slot, C1's pixels would clip to E. + let (mut s, [_, c1, e]) = stack(); + s.execute("layer.setProps", json!({"layer": e, "clipped": false})).unwrap(); + let d = s.execute("layer.new.layer", json!({})).unwrap()["layer"].as_u64().unwrap(); + s.execute("edit.fill", json!({"color": "#808080"})).unwrap(); + s.execute("layer.setProps", json!({"layer": d, "clipped": true})).unwrap(); + s.execute("layer.select", json!({"layer": c1})).unwrap(); + s.execute("layer.select", json!({"layer": d, "mode": "add"})).unwrap(); + let r = s.execute("layer.mergeLayers", json!({})).unwrap(); + assert!(!clipped(&s, r["layer"].as_u64().unwrap()), "C1 + D come from two clip chains"); + // Merging the base with its clipped layers gives an ordinary layer. + let (mut s, [b, _, c2]) = stack(); + s.execute("layer.select", json!({"layer": b})).unwrap(); + s.execute("layer.select", json!({"layer": c2, "mode": "add"})).unwrap(); + let r = s.execute("layer.mergeLayers", json!({})).unwrap(); + assert!(!clipped(&s, r["layer"].as_u64().unwrap())); +} + +#[test] +fn deleting_a_hidden_base_releases_its_clipped_layers() { + let (mut s, [b, c1, c2]) = stack(); + s.execute("layer.setProps", json!({"layer": b, "visible": false})).unwrap(); + s.execute("layer.delete.hiddenLayers", json!({})).unwrap(); + assert!(!clipped(&s, c1) && !clipped(&s, c2), "they don't clip to the layer below the deleted base"); +} From c11ef015b7640b4dad43bf775373fd345902e382 Mon Sep 17 00:00:00 2001 From: Tanish Manku Date: Thu, 15 Oct 2026 13:10:28 -0700 Subject: [PATCH 30/47] Paste Layer Style carries Blend If and channels; align to an active selection with several layers (#5881) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * Paste Layer Style carries Blend If and channels; align to an active selection with several layers From the layers audit (L2): - Copy / Paste Layer Style copied the effects, blend mode, Fill and advanced blending but dropped Blend If and the channel restrictions; Photoshop's layer style carries all of its Blending Options. Across colour modes only Blend If's Gray range carries over and the channels reset (RGB channel ranges mean nothing in CMYK). The copied style is now a named struct (CopiedStyle) instead of a tuple. - With an active selection, aligning (Layer › Align, "auto") aligned two or more layers to their own bounds; Photoshop aligns them to the selection (Layer › Align Layers to Selection), as it already did for one layer. * Paste Layer Style keeps Blend If normalised across modes; Align docs say it follows an active selection for any count; undo, group and 16-bit tests --- crates/engine/src/extra_cmds.rs | 36 ++++++++-- crates/engine/src/layer_multi_cmds.rs | 10 +-- crates/engine/src/lib.rs | 17 ++++- crates/engine/tests/style_and_align.rs | 99 ++++++++++++++++++++++++++ 4 files changed, 151 insertions(+), 11 deletions(-) create mode 100644 crates/engine/tests/style_and_align.rs diff --git a/crates/engine/src/extra_cmds.rs b/crates/engine/src/extra_cmds.rs index 65d181b955..91c9a9bc1a 100644 --- a/crates/engine/src/extra_cmds.rs +++ b/crates/engine/src/extra_cmds.rs @@ -883,8 +883,17 @@ pub fn specs() -> Vec { ), spec!("layer.layerStyle.copyLayerStyle", "Copy Layer Style", &["Layer", "Layer Style"], None, r##"{"layer":id?}"##, has_layer, |s, p| { let id = layer_param(s, p)?; - let l = s.active().and_then(|d| d.doc.layer(id)).ok_or(EngineError::NoLayer(id))?; - let style = (l.effects.clone(), l.blend, l.fill_opacity, l.advanced); + let d = s.active().ok_or(EngineError::NoDocument)?; + let l = d.doc.layer(id).ok_or(EngineError::NoLayer(id))?; + let style = crate::CopiedStyle { + effects: l.effects.clone(), + blend: l.blend, + fill: l.fill_opacity, + advanced: l.advanced, + blend_if: l.blend_if.clone(), + excluded_channels: l.excluded_channels, + mode: d.doc.mode, + }; s.style_clipboard = Some(style); Ok(Value::Null) }), @@ -900,17 +909,32 @@ pub fn specs() -> Vec { }, |s, p| { let ids = style_targets(s, p, |_| true)?; - let (mut fx, blend, fill, advanced) = s.style_clipboard.clone().ok_or(EngineError::Other("no layer style has been copied".into()))?; + let style = s.style_clipboard.clone().ok_or(EngineError::Other("no layer style has been copied".into()))?; + let mut fx = style.effects; s.edit("Paste Layer Style", |doc, _| { // A style copied from a document in another mode takes this one's colours. let mode = doc.mode; fx.items = std::mem::take(&mut fx.items).into_iter().map(|e| e.in_mode(mode)).collect(); + // Blend If's Gray range carries over to any mode; its channel ranges and the + // channel restrictions only to the mode they were set in. + let same_mode = style.mode == mode; + let blend_if = if same_mode { + style.blend_if.clone() + } else { + // Through `set`, which keeps the normalised form (no trailing full + // entries), so a full Gray range pastes as the default. + let mut gray = photocraft_doc::BlendIf::default(); + gray.set(0, style.blend_if.get(0)); + gray + }; for id in ids { let l = doc.layer_mut(id).ok_or(EngineError::NoLayer(id))?; l.effects = fx.clone(); - l.blend = blend; - l.fill_opacity = fill; - l.advanced = advanced; + l.blend = style.blend; + l.fill_opacity = style.fill; + l.advanced = style.advanced; + l.blend_if = blend_if.clone(); + l.excluded_channels = if same_mode { style.excluded_channels } else { 0 }; } Ok(()) })?; diff --git a/crates/engine/src/layer_multi_cmds.rs b/crates/engine/src/layer_multi_cmds.rs index bfbd036ec3..87bb507e4d 100644 --- a/crates/engine/src/layer_multi_cmds.rs +++ b/crates/engine/src/layer_multi_cmds.rs @@ -263,7 +263,8 @@ fn can_align(s: &Session) -> std::result::Result<(), String> { let d = doc_state(s)?; match d.selected_layers().len() { 0 => Err("no layer selected".into()), - // Photoshop aligns a single layer to the canvas (or to an active selection); 2+ align to each other. + // Photoshop aligns to an active selection however many layers are selected; without one, + // a single layer aligns to the canvas and 2+ align to each other. _ => Ok(()), } } @@ -961,8 +962,9 @@ fn align(s: &mut Session, p: &Value, kind: &str) -> Result { "canvas" => doc.bounds(), "selection" => doc.selection.as_ref().map(|m| m.content_bounds()).ok_or_else(|| EngineError::Other("there is no selection to align to".into()))?, "auto" | "layers" => match (&doc.selection, sel.len()) { - // Photoshop: one layer plus an active selection aligns to the selection bounds. - (Some(m), 1) if to == "auto" => m.content_bounds(), + // Photoshop: with an active selection, the layers align to its bounds (Layer › Align + // Layers to Selection), however many are selected. + (Some(m), _) if to == "auto" && !m.content_bounds().is_empty() => m.content_bounds(), // One layer with no selection aligns to the canvas (otherwise it would align to itself). (None, 1) if to == "auto" => doc.bounds(), _ => items.iter().fold(Rect::EMPTY, |a, (_, b)| a.union(b)), @@ -1437,7 +1439,7 @@ pub fn specs() -> Vec { CommandSpec { id: $id, label: $label, menu: $menu, shortcut: $sc, params: $params, enabled: $enabled, journal: true, run: $run } }; } - const ALIGN: &str = r##"{"to":"auto|layers|selection|canvas"="auto"} (auto: the selection bounds with one layer and an active selection, else the selected layers' bounds)"##; + const ALIGN: &str = r##"{"to":"auto|layers|selection|canvas"="auto"} (auto: the active selection's bounds when there is one, however many layers are selected; else one layer aligns to the canvas and several to their combined bounds)"##; vec![ spec!("select.allLayers", "All Layers", &["Select"], Some("Cmd+Alt+A"), "{}", has_doc, |s, _| select_all_layers(s)), spec!("select.deselectLayers", "Deselect Layers", &["Select"], None, "{}", has_doc, |s, _| { diff --git a/crates/engine/src/lib.rs b/crates/engine/src/lib.rs index d2aecf37db..2e0684de12 100644 --- a/crates/engine/src/lib.rs +++ b/crates/engine/src/lib.rs @@ -406,6 +406,21 @@ impl Default for ToolState { } } +/// Layer › Layer Style › Copy Layer Style: the effects and the Blending Options, as Photoshop +/// copies them (Paste Layer Style puts them all on each target). +#[derive(Clone, Debug)] +pub struct CopiedStyle { + pub effects: photocraft_doc::Effects, + pub blend: photocraft_color::BlendMode, + pub fill: f32, + pub advanced: photocraft_doc::AdvancedBlending, + pub blend_if: photocraft_doc::BlendIf, + pub excluded_channels: u32, + /// The colour mode it was copied in: Blend If's channel ranges and the channel restrictions + /// only carry over to a document in the same mode. + pub mode: photocraft_color::ColorMode, +} + #[derive(Default)] pub struct Session { docs: Vec, @@ -419,7 +434,7 @@ pub struct Session { pub clipboard: Option, /// Layer › Layer Style › Copy Layer Style: effects, blend mode, fill opacity and the Advanced /// Blending switches. - pub style_clipboard: Option<(photocraft_doc::Effects, photocraft_color::BlendMode, f32, photocraft_doc::AdvancedBlending)>, + pub style_clipboard: Option, /// Shape path context menu: copied vector fill and stroke styles. pub path_fill_clipboard: Option, pub path_stroke_clipboard: Option, diff --git a/crates/engine/tests/style_and_align.rs b/crates/engine/tests/style_and_align.rs new file mode 100644 index 0000000000..ad17330507 --- /dev/null +++ b/crates/engine/tests/style_and_align.rs @@ -0,0 +1,99 @@ +//! Copy / Paste Layer Style carries the whole style, Blending Options included (Blend If and the +//! channel restrictions); align with an active selection aligns to it however many layers are +//! selected (layers audit L2). + +use photocraft_engine::Session; +use serde_json::json; + +fn layer(s: &Session, id: u64) -> photocraft_doc::Layer { + s.active().unwrap().doc.layer(photocraft_doc::LayerId(id)).unwrap().clone() +} + +fn new_doc(s: &mut Session, mode: &str) -> (u64, u64) { + s.execute("file.new", json!({"width": 64, "height": 64, "mode": mode, "background": "white"})).unwrap(); + let a = s.execute("layer.new.layer", json!({})).unwrap()["layer"].as_u64().unwrap(); + let b = s.execute("layer.new.layer", json!({})).unwrap()["layer"].as_u64().unwrap(); + (a, b) +} + +#[test] +fn paste_layer_style_carries_blend_if_and_channels() { + let mut s = Session::new(); + let (a, b) = new_doc(&mut s, "rgb"); + s.execute( + "layer.layerStyle.blendingOptions", + json!({"layer": a, "blendIf": [{"channel": "gray", "underlying": [0, 128]}, {"channel": "red", "thisLayer": [10, 200]}]}), + ) + .unwrap(); + s.execute("layer.setProps", json!({"layer": a, "channels": [true, false, true]})).unwrap(); + s.execute("layer.layerStyle.copyLayerStyle", json!({"layer": a})).unwrap(); + s.execute("layer.layerStyle.pasteLayerStyle", json!({"layer": b})).unwrap(); + let (la, lb) = (layer(&s, a), layer(&s, b)); + assert_eq!(lb.blend_if, la.blend_if, "Blend If, Gray and channel ranges"); + assert_eq!(lb.excluded_channels, la.excluded_channels, "the unchecked G channel"); + // Into a CMYK document: only the Gray range carries over; channels reset to all. + let (c, _) = new_doc(&mut s, "cmyk"); + s.execute("layer.layerStyle.pasteLayerStyle", json!({"layer": c})).unwrap(); + let lc = layer(&s, c); + assert_eq!(lc.blend_if.get(0), la.blend_if.get(0)); + assert_eq!(lc.blend_if.ranges.len(), 1, "no RGB channel range on CMYK"); + assert_eq!(lc.excluded_channels, 0); +} + +#[test] +fn paste_layer_style_into_another_mode_keeps_blend_if_normalised() { + // A full Gray range and only a Red range: in CMYK nothing is left, so it is the default + // (which compares equal, so the paste isn't seen as a Blend If change). + let mut s = Session::new(); + let (a, _) = new_doc(&mut s, "rgb"); + s.execute("layer.layerStyle.blendingOptions", json!({"layer": a, "blendIf": [{"channel": "red", "thisLayer": [10, 200]}]})).unwrap(); + s.execute("layer.layerStyle.copyLayerStyle", json!({"layer": a})).unwrap(); + let (c, _) = new_doc(&mut s, "cmyk"); + s.execute("layer.layerStyle.pasteLayerStyle", json!({"layer": c})).unwrap(); + assert_eq!(layer(&s, c).blend_if, photocraft_doc::BlendIf::default()); +} + +#[test] +fn paste_layer_style_on_several_layers_is_one_undo_step() { + // 16-bit, onto a pixel layer and a group: one History step, and one Undo restores both. + let mut s = Session::new(); + s.execute("file.new", json!({"width": 64, "height": 64, "depth": 16, "background": "white"})).unwrap(); + let a = s.execute("layer.new.layer", json!({})).unwrap()["layer"].as_u64().unwrap(); + s.execute("layer.layerStyle.blendingOptions", json!({"layer": a, "blendIf": [{"channel": "gray", "underlying": [0, 128]}]})).unwrap(); + s.execute("layer.setProps", json!({"layer": a, "fill": 0.5, "blend": "multiply"})).unwrap(); + s.execute("layer.layerStyle.copyLayerStyle", json!({"layer": a})).unwrap(); + let b = s.execute("layer.new.layer", json!({})).unwrap()["layer"].as_u64().unwrap(); + let g = s.execute("layer.new.group", json!({})).unwrap()["layer"].as_u64().unwrap(); + let before = (layer(&s, b), layer(&s, g)); + s.execute("layer.select", json!({"layer": b})).unwrap(); + s.execute("layer.select", json!({"layer": g, "mode": "add"})).unwrap(); + s.execute("layer.layerStyle.pasteLayerStyle", json!({})).unwrap(); + let la = layer(&s, a); + for id in [b, g] { + let l = layer(&s, id); + assert_eq!((l.blend_if.clone(), l.fill_opacity, l.blend), (la.blend_if.clone(), la.fill_opacity, la.blend), "layer {id}"); + } + s.execute("edit.undo", json!({})).unwrap(); + let after = (layer(&s, b), layer(&s, g)); + assert_eq!((after.0.blend_if, after.0.blend, after.1.blend_if, after.1.blend), (before.0.blend_if, before.0.blend, before.1.blend_if, before.1.blend)); + assert_eq!((after.0.fill_opacity, after.1.fill_opacity), (before.0.fill_opacity, before.1.fill_opacity)); +} + +#[test] +fn several_layers_align_to_an_active_selection() { + let mut s = Session::new(); + let (a, b) = new_doc(&mut s, "rgb"); + for (id, x) in [(a, 4), (b, 30)] { + s.execute("layer.select", json!({"layer": id})).unwrap(); + s.execute("select.rect", json!({"x": x, "y": 10, "width": 8, "height": 8})).unwrap(); + s.execute("edit.fill", json!({"color": "#ff0000"})).unwrap(); + } + s.execute("select.rect", json!({"x": 40, "y": 40, "width": 20, "height": 20})).unwrap(); + s.execute("layer.select", json!({"layer": a})).unwrap(); + s.execute("layer.select", json!({"layer": b, "mode": "add"})).unwrap(); + s.execute("layer.align.leftEdges", json!({})).unwrap(); + for id in [a, b] { + let x0 = layer(&s, id).surface().unwrap().content_bounds().x0; + assert_eq!(x0, 40, "layer {id} aligned to the selection's left edge"); + } +} From 04db697b5d350b64cc05942fdf7d3c4df2fd4d5e Mon Sep 17 00:00:00 2001 From: Tanish Manku Date: Thu, 15 Oct 2026 14:17:22 -0700 Subject: [PATCH 31/47] Paste Layer Style: a group's Pass Through pastes onto other layers as Normal; style clipboard comment (#6310) --- crates/engine/src/extra_cmds.rs | 8 +++++++- crates/engine/src/lib.rs | 4 ++-- crates/engine/tests/style_and_align.rs | 16 ++++++++++++++++ 3 files changed, 25 insertions(+), 3 deletions(-) diff --git a/crates/engine/src/extra_cmds.rs b/crates/engine/src/extra_cmds.rs index 91c9a9bc1a..c291862167 100644 --- a/crates/engine/src/extra_cmds.rs +++ b/crates/engine/src/extra_cmds.rs @@ -930,7 +930,13 @@ pub fn specs() -> Vec { for id in ids { let l = doc.layer_mut(id).ok_or(EngineError::NoLayer(id))?; l.effects = fx.clone(); - l.blend = style.blend; + // Pass Through is a group's mode: a group's style pastes onto other layers as + // Normal, as a merged group's Pass Through becomes Normal. + l.blend = if style.blend == photocraft_color::BlendMode::PassThrough && !l.is_group() { + photocraft_color::BlendMode::Normal + } else { + style.blend + }; l.fill_opacity = style.fill; l.advanced = style.advanced; l.blend_if = blend_if.clone(); diff --git a/crates/engine/src/lib.rs b/crates/engine/src/lib.rs index 2e0684de12..ba618f6ac8 100644 --- a/crates/engine/src/lib.rs +++ b/crates/engine/src/lib.rs @@ -432,8 +432,8 @@ pub struct Session { coalesce_request: Option, /// Pixels copied with Edit › Copy / Cut (shared by all documents, like Photoshop). pub clipboard: Option, - /// Layer › Layer Style › Copy Layer Style: effects, blend mode, fill opacity and the Advanced - /// Blending switches. + /// Layer › Layer Style › Copy Layer Style: the effects and the Blending Options + /// ([`CopiedStyle`]: blend mode, fill opacity, Advanced Blending, Blend If, channels). pub style_clipboard: Option, /// Shape path context menu: copied vector fill and stroke styles. pub path_fill_clipboard: Option, diff --git a/crates/engine/tests/style_and_align.rs b/crates/engine/tests/style_and_align.rs index ad17330507..9af9bed0fa 100644 --- a/crates/engine/tests/style_and_align.rs +++ b/crates/engine/tests/style_and_align.rs @@ -97,3 +97,19 @@ fn several_layers_align_to_an_active_selection() { assert_eq!(x0, 40, "layer {id} aligned to the selection's left edge"); } } + +#[test] +fn a_groups_pass_through_pastes_onto_a_pixel_layer_as_normal() { + let mut s = Session::new(); + let (a, _) = new_doc(&mut s, "rgb"); + let g = s.execute("layer.new.group", json!({})).unwrap()["layer"].as_u64().unwrap(); + assert_eq!(layer(&s, g).blend, photocraft_color::BlendMode::PassThrough); + s.execute("layer.layerStyle.copyLayerStyle", json!({"layer": g})).unwrap(); + s.execute("layer.layerStyle.pasteLayerStyle", json!({"layer": a})).unwrap(); + assert_eq!(layer(&s, a).blend, photocraft_color::BlendMode::Normal, "Pass Through isn't a pixel layer's mode"); + // Onto another group it stays Pass Through. + let g2 = s.execute("layer.new.group", json!({})).unwrap()["layer"].as_u64().unwrap(); + s.execute("layer.setProps", json!({"layer": g2, "blend": "Multiply"})).unwrap(); + s.execute("layer.layerStyle.pasteLayerStyle", json!({"layer": g2})).unwrap(); + assert_eq!(layer(&s, g2).blend, photocraft_color::BlendMode::PassThrough); +} From 3cd0f6c954952aaf1e4b5f9db4bb344ae0b86b20 Mon Sep 17 00:00:00 2001 From: Tanish Manku Date: Thu, 15 Oct 2026 14:18:14 -0700 Subject: [PATCH 32/47] Keyboard Shortcuts: Accept and Go to Conflict / Undo Changes, and reserved keys (K4a) (#5349) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * Keyboard Shortcuts: Accept and Go to Conflict / Undo Changes, and reserved keys (K4a) A key that another command holds now offers Photoshop's two answers under the warning. Accept and Go to Conflict takes the key from the other command at once (its main key or an alternate) and selects it for a new one. Undo Changes puts the edited command's main and further keys back as they were. OK without either still accepts, as in Photoshop. Keys the system takes before the app sees them are refused with a message: Alt+Tab, Alt+F4, Ctrl+Alt+Delete, Ctrl+Esc and Ctrl+Shift+Esc off the Mac; Cmd+Tab, Cmd+` and Cmd+Opt+Esc on it. * Keyboard Shortcuts: a conflict goes stale when the bindings change after it (Reset All, Use Default, Delete, ×, Escape, a set switch); Accept takes the key from every holder; Linux reserves its own keys; doc comment back on shortcut_text --- crates/ui-egui/src/i18n/cs.tsv | 7 ++ crates/ui-egui/src/i18n/de.tsv | 7 ++ crates/ui-egui/src/i18n/el.tsv | 7 ++ crates/ui-egui/src/i18n/es.tsv | 7 ++ crates/ui-egui/src/i18n/fr.tsv | 7 ++ crates/ui-egui/src/i18n/hu.tsv | 7 ++ crates/ui-egui/src/i18n/id.tsv | 7 ++ crates/ui-egui/src/i18n/it.tsv | 7 ++ crates/ui-egui/src/i18n/ja.tsv | 7 ++ crates/ui-egui/src/i18n/ko.tsv | 7 ++ crates/ui-egui/src/i18n/nl.tsv | 7 ++ crates/ui-egui/src/i18n/pl.tsv | 7 ++ crates/ui-egui/src/i18n/pt-br.tsv | 7 ++ crates/ui-egui/src/i18n/ru.tsv | 7 ++ crates/ui-egui/src/i18n/sr.tsv | 7 ++ crates/ui-egui/src/i18n/tr.tsv | 7 ++ crates/ui-egui/src/i18n/uk.tsv | 7 ++ crates/ui-egui/src/i18n/vi.tsv | 7 ++ crates/ui-egui/src/i18n/zh-hans.tsv | 7 ++ crates/ui-egui/src/i18n/zh-hant.tsv | 7 ++ crates/ui-egui/src/prefs_ui.rs | 122 ++++++++++++++++++- crates/ui-egui/src/shortcut_capture_tests.rs | 116 ++++++++++++++++++ 22 files changed, 377 insertions(+), 1 deletion(-) diff --git a/crates/ui-egui/src/i18n/cs.tsv b/crates/ui-egui/src/i18n/cs.tsv index 6c8f21418a..376757e866 100644 --- a/crates/ui-egui/src/i18n/cs.tsv +++ b/crates/ui-egui/src/i18n/cs.tsv @@ -3797,3 +3797,10 @@ navigator Zoom Zvětšení There are too many sets: delete one first. Sad je příliš mnoho: nejprve jednu smažte. set name název sady {set} (modified) {set} (změněno) + Accept and Go to Conflict Přijmout a přejít ke konfliktu + Changes undone. Změny vráceny. + Choose a new shortcut for this command, or leave it without one. Zvolte pro tento příkaz novou zkratku, nebo ho nechte bez ní. + Put this command's shortcuts back as they were Vrátit zkratky tohoto příkazu do původního stavu + Take the shortcut from the other command and select it Odebrat zkratku druhému příkazu a vybrat ho + Undo Changes Vrátit změny + {shortcut} is reserved by the system and can't be assigned. {shortcut} je vyhrazena systémem a nelze ji přiřadit. diff --git a/crates/ui-egui/src/i18n/de.tsv b/crates/ui-egui/src/i18n/de.tsv index 2bb1c57f19..59a9d470cd 100644 --- a/crates/ui-egui/src/i18n/de.tsv +++ b/crates/ui-egui/src/i18n/de.tsv @@ -3191,3 +3191,10 @@ curves Smooth Glätten There are too many sets: delete one first. Zu viele Sätze: Löschen Sie zuerst einen. set name Name des Satzes {set} (modified) {set} (geändert) + Accept and Go to Conflict Übernehmen und zum Konflikt + Changes undone. Änderungen rückgängig gemacht. + Choose a new shortcut for this command, or leave it without one. Wählen Sie einen neuen Kurzbefehl für diesen Befehl oder lassen Sie ihn ohne. + Put this command's shortcuts back as they were Die Kurzbefehle dieses Befehls wiederherstellen + Take the shortcut from the other command and select it Den Kurzbefehl dem anderen Befehl entziehen und diesen auswählen + Undo Changes Änderungen rückgängig machen + {shortcut} is reserved by the system and can't be assigned. {shortcut} ist vom System reserviert und kann nicht zugewiesen werden. diff --git a/crates/ui-egui/src/i18n/el.tsv b/crates/ui-egui/src/i18n/el.tsv index 48d23d13b9..0a2e6ddc7c 100644 --- a/crates/ui-egui/src/i18n/el.tsv +++ b/crates/ui-egui/src/i18n/el.tsv @@ -3889,3 +3889,10 @@ curves Smooth Εξομάλυνση There are too many sets: delete one first. Υπάρχουν πάρα πολλά σύνολα: διαγράψτε πρώτα ένα. set name όνομα συνόλου {set} (modified) {set} (τροποποιημένο) + Accept and Go to Conflict Αποδοχή και μετάβαση στη σύγκρουση + Changes undone. Οι αλλαγές αναιρέθηκαν. + Choose a new shortcut for this command, or leave it without one. Επιλέξτε νέα συντόμευση για αυτήν την εντολή ή αφήστε την χωρίς. + Put this command's shortcuts back as they were Επαναφορά των συντομεύσεων αυτής της εντολής όπως ήταν + Take the shortcut from the other command and select it Αφαίρεση της συντόμευσης από την άλλη εντολή και επιλογή της + Undo Changes Αναίρεση αλλαγών + {shortcut} is reserved by the system and can't be assigned. Η συντόμευση {shortcut} δεσμεύεται από το σύστημα και δεν μπορεί να εκχωρηθεί. diff --git a/crates/ui-egui/src/i18n/es.tsv b/crates/ui-egui/src/i18n/es.tsv index a7338795aa..2069b068e5 100644 --- a/crates/ui-egui/src/i18n/es.tsv +++ b/crates/ui-egui/src/i18n/es.tsv @@ -4151,3 +4151,10 @@ navigator Zoom Zoom There are too many sets: delete one first. Hay demasiados conjuntos: elimine uno primero. set name nombre del conjunto {set} (modified) {set} (modificado) + Accept and Go to Conflict Aceptar e ir al conflicto + Changes undone. Cambios deshechos. + Choose a new shortcut for this command, or leave it without one. Elija un nuevo método abreviado para este comando o déjelo sin ninguno. + Put this command's shortcuts back as they were Restaurar los métodos abreviados de este comando como estaban + Take the shortcut from the other command and select it Quitar el método abreviado al otro comando y seleccionarlo + Undo Changes Deshacer cambios + {shortcut} is reserved by the system and can't be assigned. {shortcut} está reservado por el sistema y no se puede asignar. diff --git a/crates/ui-egui/src/i18n/fr.tsv b/crates/ui-egui/src/i18n/fr.tsv index ba1de7b706..d528f1e7ce 100644 --- a/crates/ui-egui/src/i18n/fr.tsv +++ b/crates/ui-egui/src/i18n/fr.tsv @@ -3194,3 +3194,10 @@ curves Smooth Lisser set name nom du jeu {set} (modified) {set} (modifié) Random Aléatoire + Accept and Go to Conflict Accepter et aller au conflit + Changes undone. Modifications annulées. + Choose a new shortcut for this command, or leave it without one. Choisissez un nouveau raccourci pour cette commande ou laissez-la sans raccourci. + Put this command's shortcuts back as they were Rétablir les raccourcis de cette commande + Take the shortcut from the other command and select it Retirer le raccourci de l’autre commande et la sélectionner + Undo Changes Annuler les modifications + {shortcut} is reserved by the system and can't be assigned. {shortcut} est réservé par le système et ne peut pas être attribué. diff --git a/crates/ui-egui/src/i18n/hu.tsv b/crates/ui-egui/src/i18n/hu.tsv index e26f3ec3b8..e9f3d33907 100644 --- a/crates/ui-egui/src/i18n/hu.tsv +++ b/crates/ui-egui/src/i18n/hu.tsv @@ -4108,3 +4108,10 @@ cameraRaw Yellow Sárga There are too many sets: delete one first. Túl sok készlet van: előbb töröljön egyet. set name készlet neve {set} (modified) {set} (módosítva) + Accept and Go to Conflict Elfogadás és ugrás az ütközéshez + Changes undone. Változások visszavonva. + Choose a new shortcut for this command, or leave it without one. Válasszon új billentyűparancsot ehhez a parancshoz, vagy hagyja nélküle. + Put this command's shortcuts back as they were A parancs billentyűparancsainak visszaállítása + Take the shortcut from the other command and select it A billentyűparancs elvétele a másik parancstól, és annak kijelölése + Undo Changes Változások visszavonása + {shortcut} is reserved by the system and can't be assigned. A(z) {shortcut} a rendszer számára fenntartott, nem rendelhető hozzá. diff --git a/crates/ui-egui/src/i18n/id.tsv b/crates/ui-egui/src/i18n/id.tsv index d13db9f0cf..141f956c8e 100644 --- a/crates/ui-egui/src/i18n/id.tsv +++ b/crates/ui-egui/src/i18n/id.tsv @@ -3181,3 +3181,10 @@ curves Smooth Haluskan set name nama set {set} (modified) {set} (diubah) Random Acak + Accept and Go to Conflict Terima dan Buka Konflik + Changes undone. Perubahan dibatalkan. + Choose a new shortcut for this command, or leave it without one. Pilih pintasan baru untuk perintah ini, atau biarkan tanpa pintasan. + Put this command's shortcuts back as they were Kembalikan pintasan perintah ini seperti semula + Take the shortcut from the other command and select it Ambil pintasan dari perintah lain dan pilih perintah itu + Undo Changes Batalkan Perubahan + {shortcut} is reserved by the system and can't be assigned. {shortcut} dicadangkan oleh sistem dan tidak dapat ditetapkan. diff --git a/crates/ui-egui/src/i18n/it.tsv b/crates/ui-egui/src/i18n/it.tsv index 1779c1259e..714932312e 100644 --- a/crates/ui-egui/src/i18n/it.tsv +++ b/crates/ui-egui/src/i18n/it.tsv @@ -4310,3 +4310,10 @@ shape Appearance Aspetto There are too many sets: delete one first. Ci sono troppi set: eliminane prima uno. set name nome del set {set} (modified) {set} (modificato) + Accept and Go to Conflict Accetta e vai al conflitto + Changes undone. Modifiche annullate. + Choose a new shortcut for this command, or leave it without one. Scegli una nuova scorciatoia per questo comando o lascialo senza. + Put this command's shortcuts back as they were Ripristina le scorciatoie di questo comando com'erano + Take the shortcut from the other command and select it Togli la scorciatoia all'altro comando e selezionalo + Undo Changes Annulla modifiche + {shortcut} is reserved by the system and can't be assigned. {shortcut} è riservata dal sistema e non può essere assegnata. diff --git a/crates/ui-egui/src/i18n/ja.tsv b/crates/ui-egui/src/i18n/ja.tsv index dd0cca727f..a78fe8e7ca 100644 --- a/crates/ui-egui/src/i18n/ja.tsv +++ b/crates/ui-egui/src/i18n/ja.tsv @@ -3865,3 +3865,10 @@ curves Smooth スムーズ There are too many sets: delete one first. セットが多すぎます。先に 1 つ削除してください。 set name セット名 {set} (modified) {set}(変更あり) + Accept and Go to Conflict 適用して競合へ移動 + Changes undone. 変更を取り消しました。 + Choose a new shortcut for this command, or leave it without one. このコマンドに新しいショートカットを選択するか、なしのままにしてください。 + Put this command's shortcuts back as they were このコマンドのショートカットを元に戻す + Take the shortcut from the other command and select it もう一方のコマンドからショートカットを外して選択 + Undo Changes 変更を取り消し + {shortcut} is reserved by the system and can't be assigned. {shortcut} はシステムで予約されているため割り当てられません。 diff --git a/crates/ui-egui/src/i18n/ko.tsv b/crates/ui-egui/src/i18n/ko.tsv index f5c243d1e5..2e12c33799 100644 --- a/crates/ui-egui/src/i18n/ko.tsv +++ b/crates/ui-egui/src/i18n/ko.tsv @@ -3367,3 +3367,10 @@ curves Smooth 매끄럽게 There are too many sets: delete one first. 세트가 너무 많습니다. 먼저 하나를 삭제하세요. set name 세트 이름 {set} (modified) {set}(수정됨) + Accept and Go to Conflict 적용 후 충돌 항목으로 이동 + Changes undone. 변경 사항을 취소했습니다. + Choose a new shortcut for this command, or leave it without one. 이 명령의 새 단축키를 선택하거나 단축키 없이 두세요. + Put this command's shortcuts back as they were 이 명령의 단축키를 원래대로 되돌리기 + Take the shortcut from the other command and select it 다른 명령에서 단축키를 빼고 그 명령 선택 + Undo Changes 변경 사항 취소 + {shortcut} is reserved by the system and can't be assigned. {shortcut}은(는) 시스템에서 예약되어 할당할 수 없습니다. diff --git a/crates/ui-egui/src/i18n/nl.tsv b/crates/ui-egui/src/i18n/nl.tsv index aea44757ca..32df1e20a2 100644 --- a/crates/ui-egui/src/i18n/nl.tsv +++ b/crates/ui-egui/src/i18n/nl.tsv @@ -3949,3 +3949,10 @@ curves Smooth Vloeiend maken There are too many sets: delete one first. Er zijn te veel sets: verwijder er eerst een. set name naam van de set {set} (modified) {set} (gewijzigd) + Accept and Go to Conflict Accepteren en naar conflict + Changes undone. Wijzigingen ongedaan gemaakt. + Choose a new shortcut for this command, or leave it without one. Kies een nieuwe sneltoets voor deze opdracht of laat hem zonder. + Put this command's shortcuts back as they were De sneltoetsen van deze opdracht herstellen + Take the shortcut from the other command and select it De sneltoets van de andere opdracht afnemen en die selecteren + Undo Changes Wijzigingen ongedaan maken + {shortcut} is reserved by the system and can't be assigned. {shortcut} is door het systeem gereserveerd en kan niet worden toegewezen. diff --git a/crates/ui-egui/src/i18n/pl.tsv b/crates/ui-egui/src/i18n/pl.tsv index 17feb459aa..8b571ff7d6 100644 --- a/crates/ui-egui/src/i18n/pl.tsv +++ b/crates/ui-egui/src/i18n/pl.tsv @@ -3787,3 +3787,10 @@ curves Smooth Wygładź There are too many sets: delete one first. Zestawów jest za dużo: najpierw usuń jeden. set name nazwa zestawu {set} (modified) {set} (zmieniony) + Accept and Go to Conflict Akceptuj i przejdź do konfliktu + Changes undone. Zmiany cofnięte. + Choose a new shortcut for this command, or leave it without one. Wybierz nowy skrót dla tego polecenia lub zostaw je bez skrótu. + Put this command's shortcuts back as they were Przywróć skróty tego polecenia + Take the shortcut from the other command and select it Odbierz skrót drugiemu poleceniu i zaznacz je + Undo Changes Cofnij zmiany + {shortcut} is reserved by the system and can't be assigned. {shortcut} jest zarezerwowany przez system i nie można go przypisać. diff --git a/crates/ui-egui/src/i18n/pt-br.tsv b/crates/ui-egui/src/i18n/pt-br.tsv index 0e9559c80e..8e6dcc4993 100644 --- a/crates/ui-egui/src/i18n/pt-br.tsv +++ b/crates/ui-egui/src/i18n/pt-br.tsv @@ -3979,3 +3979,10 @@ layerLabel No Color Sem cor There are too many sets: delete one first. Há conjuntos demais: exclua um primeiro. set name nome do conjunto {set} (modified) {set} (modificado) + Accept and Go to Conflict Aceitar e ir para o conflito + Changes undone. Alterações desfeitas. + Choose a new shortcut for this command, or leave it without one. Escolha um novo atalho para este comando ou deixe-o sem atalho. + Put this command's shortcuts back as they were Restaurar os atalhos deste comando como estavam + Take the shortcut from the other command and select it Tirar o atalho do outro comando e selecioná-lo + Undo Changes Desfazer alterações + {shortcut} is reserved by the system and can't be assigned. {shortcut} é reservado pelo sistema e não pode ser atribuído. diff --git a/crates/ui-egui/src/i18n/ru.tsv b/crates/ui-egui/src/i18n/ru.tsv index 78e10a10c4..6c80c470cf 100644 --- a/crates/ui-egui/src/i18n/ru.tsv +++ b/crates/ui-egui/src/i18n/ru.tsv @@ -3159,3 +3159,10 @@ curves Smooth Сгладить set name имя набора {set} (modified) {set} (изменён) Random Случайно + Accept and Go to Conflict Принять и перейти к конфликту + Changes undone. Изменения отменены. + Choose a new shortcut for this command, or leave it without one. Выберите новую комбинацию для этой команды или оставьте её без комбинации. + Put this command's shortcuts back as they were Вернуть комбинации этой команды + Take the shortcut from the other command and select it Снять комбинацию с другой команды и выбрать её + Undo Changes Отменить изменения + {shortcut} is reserved by the system and can't be assigned. Комбинация {shortcut} зарезервирована системой и не может быть назначена. diff --git a/crates/ui-egui/src/i18n/sr.tsv b/crates/ui-egui/src/i18n/sr.tsv index f4a8552fb0..9b30313e22 100644 --- a/crates/ui-egui/src/i18n/sr.tsv +++ b/crates/ui-egui/src/i18n/sr.tsv @@ -4262,3 +4262,10 @@ shape Appearance Izgled There are too many sets: delete one first. Previše skupova: prvo obrišite jedan. set name ime skupa {set} (modified) {set} (izmenjen) + Accept and Go to Conflict Prihvati i idi na sukob + Changes undone. Izmene su poništene. + Choose a new shortcut for this command, or leave it without one. Izaberite novu prečicu za ovu komandu ili je ostavite bez prečice. + Put this command's shortcuts back as they were Vrati prečice ove komande kakve su bile + Take the shortcut from the other command and select it Uzmi prečicu drugoj komandi i izaberi je + Undo Changes Poništi izmene + {shortcut} is reserved by the system and can't be assigned. {shortcut} je rezervisana od strane sistema i ne može se dodeliti. diff --git a/crates/ui-egui/src/i18n/tr.tsv b/crates/ui-egui/src/i18n/tr.tsv index b11f51a807..8e20ad53dc 100644 --- a/crates/ui-egui/src/i18n/tr.tsv +++ b/crates/ui-egui/src/i18n/tr.tsv @@ -3919,3 +3919,10 @@ cameraData Lens Lens There are too many sets: delete one first. Çok fazla küme var: önce birini silin. set name küme adı {set} (modified) {set} (değiştirildi) + Accept and Go to Conflict Kabul Et ve Çakışmaya Git + Changes undone. Değişiklikler geri alındı. + Choose a new shortcut for this command, or leave it without one. Bu komut için yeni bir kısayol seçin veya kısayolsuz bırakın. + Put this command's shortcuts back as they were Bu komutun kısayollarını eski haline getir + Take the shortcut from the other command and select it Kısayolu diğer komuttan al ve o komutu seç + Undo Changes Değişiklikleri Geri Al + {shortcut} is reserved by the system and can't be assigned. {shortcut} sistem tarafından ayrılmıştır ve atanamaz. diff --git a/crates/ui-egui/src/i18n/uk.tsv b/crates/ui-egui/src/i18n/uk.tsv index e563354fdf..9fcf5e85bc 100644 --- a/crates/ui-egui/src/i18n/uk.tsv +++ b/crates/ui-egui/src/i18n/uk.tsv @@ -3888,3 +3888,10 @@ curves Smooth Згладити There are too many sets: delete one first. Забагато наборів: спершу видаліть один. set name назва набору {set} (modified) {set} (змінено) + Accept and Go to Conflict Прийняти й перейти до конфлікту + Changes undone. Зміни скасовано. + Choose a new shortcut for this command, or leave it without one. Виберіть нове клавіатурне скорочення для цієї команди або залиште її без нього. + Put this command's shortcuts back as they were Повернути клавіатурні скорочення цієї команди + Take the shortcut from the other command and select it Забрати скорочення в іншої команди й вибрати її + Undo Changes Скасувати зміни + {shortcut} is reserved by the system and can't be assigned. {shortcut} зарезервовано системою, його не можна призначити. diff --git a/crates/ui-egui/src/i18n/vi.tsv b/crates/ui-egui/src/i18n/vi.tsv index 6907be96aa..515966c515 100644 --- a/crates/ui-egui/src/i18n/vi.tsv +++ b/crates/ui-egui/src/i18n/vi.tsv @@ -3675,3 +3675,10 @@ curves Smooth Làm mượt There are too many sets: delete one first. Có quá nhiều bộ: hãy xóa một bộ trước. set name tên bộ {set} (modified) {set} (đã sửa đổi) + Accept and Go to Conflict Chấp nhận và đến xung đột + Changes undone. Đã hoàn tác thay đổi. + Choose a new shortcut for this command, or leave it without one. Chọn phím tắt mới cho lệnh này, hoặc để lệnh không có phím tắt. + Put this command's shortcuts back as they were Khôi phục phím tắt của lệnh này như cũ + Take the shortcut from the other command and select it Lấy phím tắt khỏi lệnh kia và chọn lệnh đó + Undo Changes Hoàn tác thay đổi + {shortcut} is reserved by the system and can't be assigned. {shortcut} được hệ thống dành riêng và không thể gán. diff --git a/crates/ui-egui/src/i18n/zh-hans.tsv b/crates/ui-egui/src/i18n/zh-hans.tsv index 3962f46d65..cbf3c9d08f 100644 --- a/crates/ui-egui/src/i18n/zh-hans.tsv +++ b/crates/ui-egui/src/i18n/zh-hans.tsv @@ -2805,3 +2805,10 @@ curves Smooth 平滑 set name 组名称 {set} (modified) {set}(已修改) Random 随机 + Accept and Go to Conflict 接受并转到冲突 + Changes undone. 已撤消更改。 + Choose a new shortcut for this command, or leave it without one. 为此命令选择新的快捷键,或保留为无快捷键。 + Put this command's shortcuts back as they were 将此命令的快捷键恢复原样 + Take the shortcut from the other command and select it 从另一个命令移除该快捷键并选中它 + Undo Changes 撤消更改 + {shortcut} is reserved by the system and can't be assigned. {shortcut} 由系统保留,无法指定。 diff --git a/crates/ui-egui/src/i18n/zh-hant.tsv b/crates/ui-egui/src/i18n/zh-hant.tsv index 005c992e23..30aa3f5a94 100644 --- a/crates/ui-egui/src/i18n/zh-hant.tsv +++ b/crates/ui-egui/src/i18n/zh-hant.tsv @@ -2790,3 +2790,10 @@ curves Smooth 平滑 set name 組合名稱 {set} (modified) {set}(已修改) Random 隨機 + Accept and Go to Conflict 接受並前往衝突 + Changes undone. 已還原變更。 + Choose a new shortcut for this command, or leave it without one. 為此指令選擇新的快速鍵,或保持沒有快速鍵。 + Put this command's shortcuts back as they were 將此指令的快速鍵恢復原狀 + Take the shortcut from the other command and select it 從另一個指令移除該快速鍵並選取它 + Undo Changes 還原變更 + {shortcut} is reserved by the system and can't be assigned. {shortcut} 由系統保留,無法指定。 diff --git a/crates/ui-egui/src/prefs_ui.rs b/crates/ui-egui/src/prefs_ui.rs index d194803020..99750ae7f1 100644 --- a/crates/ui-egui/src/prefs_ui.rs +++ b/crates/ui-egui/src/prefs_ui.rs @@ -804,6 +804,47 @@ pub fn shortcut_items(app: &PhotocraftApp) -> Vec<(String, String, Vec, out } +/// A fingerprint of the bindings a shortcut conflict describes. +fn conflict_state(overrides: &BTreeMap, alternates: &BTreeMap>, selected: &str, set: Option<&Value>) -> u64 { + use std::hash::{Hash, Hasher}; + let mut h = std::collections::hash_map::DefaultHasher::new(); + (overrides, alternates, selected, set.map(Value::to_string)).hash(&mut h); + h.finish() +} + +/// Keys the system takes before the app sees them (switching apps, closing the window, force +/// quit): Edit › Keyboard Shortcuts won't assign them, as Photoshop won't. `sc` is in our +/// notation, where `Cmd` is Ctrl off the Mac. +pub fn reserved_shortcut(sc: &str) -> bool { + let Some(n) = prefs::normalize_shortcut(sc) else { return false }; + let reserved: &[&str] = if cfg!(target_os = "macos") { + &["Cmd+Tab", "Cmd+Shift+Tab", "Cmd+Alt+Escape", "Cmd+`"] + } else if cfg!(target_os = "windows") { + &["Alt+Tab", "Alt+Shift+Tab", "Alt+F4", "Cmd+Alt+Delete", "Cmd+Escape", "Cmd+Shift+Escape"] + } else { + // Linux and the BSDs: window switching and closing, and the virtual consoles. + &[ + "Alt+Tab", + "Alt+Shift+Tab", + "Alt+F4", + "Cmd+Alt+Delete", + "Cmd+Alt+F1", + "Cmd+Alt+F2", + "Cmd+Alt+F3", + "Cmd+Alt+F4", + "Cmd+Alt+F5", + "Cmd+Alt+F6", + "Cmd+Alt+F7", + "Cmd+Alt+F8", + "Cmd+Alt+F9", + "Cmd+Alt+F10", + "Cmd+Alt+F11", + "Cmd+Alt+F12", + ] + }; + reserved.iter().filter_map(|r| prefs::normalize_shortcut(r)).any(|r| r == n) +} + /// Text of a key press for the shortcut editor (`Cmd+Shift+K`), `None` for bare modifiers. pub fn shortcut_text(key: egui::Key, m: egui::Modifiers) -> Option { if is_modifier_key(key) { @@ -1687,6 +1728,9 @@ fn shortcuts_body(app: &mut PhotocraftApp, ui: &mut egui::Ui, f: &mut Map = f.get("conflict").filter(|c| c.is_object()).cloned(); let mut hidden: Vec = f.get("hidden").and_then(|v| serde_json::from_value(v.clone()).ok()).unwrap_or_default(); let mut colors: BTreeMap = f.get("colors").and_then(|v| serde_json::from_value(v.clone()).ok()).unwrap_or_default(); let mut selected = f.get("selected").and_then(Value::as_str).unwrap_or("").to_string(); @@ -1739,6 +1783,7 @@ fn shortcuts_body(app: &mut PhotocraftApp, ui: &mut egui::Ui, f: &mut Map = items + let clash_ids: Vec<&str> = items .iter() .filter(|(id, _, _, def)| { let cur = eff(&overrides, id, def); @@ -1757,11 +1806,30 @@ fn shortcuts_body(app: &mut PhotocraftApp, ui: &mut egui::Ui, f: &mut Map = items + .iter() + .filter(|(id, ..)| clash_ids.contains(&id.as_str())) .map(|(id, label, path, _)| { let (path, label) = shown(id, label, path); format!("{} › {}", path.join(" › "), label.trim_end_matches('…')) }) .collect(); + // What Undo Changes puts back: the command's main key and further keys as they were. + if let Some(first) = clash_ids.first() { + conflict = Some(json!({ + "shortcut": sc, + "with": first, + // Every command holding the key: Accept takes it from all of them. + "holders": clash_ids, + "command": selected, + "main": overrides.get(&selected), + "alternates": alt_overrides.get(&selected), + })); + } else { + conflict = None; + } if capture_alt { let def = items.iter().find(|i| i.0 == selected).and_then(|i| i.3.clone()); let cur = eff(&overrides, &selected, &def); @@ -1925,6 +1993,53 @@ fn shortcuts_body(app: &mut PhotocraftApp, ui: &mut egui::Ui, f: &mut Map = serde_json::from_value(c["holders"].clone()).unwrap_or_else(|_| vec![with.to_string()]); + for holder in &holders { + let def = items.iter().find(|i| i.0 == *holder).and_then(|i| i.3.clone()); + let cur = eff(&overrides, holder, &def); + if cur.as_deref().and_then(prefs::normalize_shortcut).as_deref() == Some(key) { + overrides.insert(holder.clone(), String::new()); + } + let keys = eff_alts(&alt_overrides, holder, eff(&overrides, holder, &def).as_deref()); + if keys.iter().any(|k| k == key) { + alt_overrides.insert(holder.clone(), keys.into_iter().filter(|k| k != key).collect()); + } + } + selected = with.to_string(); + filter.clear(); + message = tl!("Choose a new shortcut for this command, or leave it without one.").into(); + message_warning = false; + conflict = None; + } + if ui.button(tl!("Undo Changes")).on_hover_text(tl!("Put this command's shortcuts back as they were")).clicked() { + match c["main"].as_str() { + Some(m) => overrides.insert(command.to_string(), m.to_string()), + None => overrides.remove(command), + }; + match serde_json::from_value::>(c["alternates"].clone()) { + Ok(list) => alt_overrides.insert(command.to_string(), list), + Err(_) => alt_overrides.remove(command), + }; + selected = command.to_string(); + message = tl!("Changes undone.").into(); + message_warning = false; + conflict = None; + } + }); + } if overrides_ok { f.insert("overrides".into(), json!(overrides)); } @@ -1932,6 +2047,11 @@ fn shortcuts_body(app: &mut PhotocraftApp, ui: &mut egui::Ui, f: &mut Map = crate::shortcut_dispatch::bindings(&app).into_iter().filter(|(_, sc)| *sc == f7).map(|(id, _)| id).collect(); assert_eq!(bound, ["select.modify.feather"]); } + +/// Capture `k` with `m` for the selected command. +fn capture_key(h: &mut Harness<'_, PhotocraftApp>, id: u64, k: Key, m: Modifiers) { + h.state_mut().ui.dialog_mut(id).unwrap().fields.insert("capture".into(), json!(true)); + h.run_steps(2); + key(h, k, true, m); + h.run_steps(2); + key(h, k, false, m); + h.run_steps(2); +} + +fn click(h: &mut Harness<'_, PhotocraftApp>, label: &str) { + h.get_by_role_and_label(egui::accesskit::Role::Button, label).click(); + h.run_steps(3); +} + +#[test] +fn a_conflict_offers_accept_and_go_to_conflict() { + // Photoshop: the key is taken from Copy now, and Copy is selected to get a new one. + let (mut h, id) = dialog(1.0, "edit.cut", ""); + capture_key(&mut h, id, Key::C, Modifiers::COMMAND); + assert_eq!(field(&h, id, "overrides")["edit.cut"], json!("Cmd+C")); + assert_eq!(field(&h, id, "conflict")["with"], json!("edit.copy")); + click(&mut h, "Accept and Go to Conflict"); + assert_eq!(field(&h, id, "selected"), json!("edit.copy")); + assert_eq!(field(&h, id, "overrides")["edit.copy"], json!(""), "Copy no longer holds Cmd+C"); + assert!(field(&h, id, "conflict").is_null()); + crate::dialogs::confirm(h.state_mut(), id).unwrap(); + assert_eq!(crate::shortcuts::effective_shortcut(h.state(), "edit.cut", Some("Cmd+X")).as_deref(), Some("Cmd+C")); + assert_eq!(crate::shortcuts::effective_shortcut(h.state(), "edit.copy", Some("Cmd+C")), None); +} + +#[test] +fn undo_changes_puts_the_command_back() { + let (mut h, id) = dialog(1.0, "edit.cut", ""); + capture_key(&mut h, id, Key::C, Modifiers::COMMAND); + click(&mut h, "Undo Changes"); + assert!(field(&h, id, "overrides").get("edit.cut").is_none(), "{}", field(&h, id, "overrides")); + assert!(field(&h, id, "conflict").is_null()); + // An alternate added with Add Shortcut is undone the same way. + h.state_mut().ui.dialog_mut(id).unwrap().fields.insert("captureAlt".into(), json!(true)); + capture_key(&mut h, id, Key::C, Modifiers::COMMAND); + assert_eq!(field(&h, id, "alternates")["edit.cut"], json!(["F2", "Cmd+C"]), "beside Photoshop's F2"); + click(&mut h, "Undo Changes"); + assert!(field(&h, id, "alternates").get("edit.cut").is_none(), "{}", field(&h, id, "alternates")); + crate::dialogs::confirm(h.state_mut(), id).unwrap(); + assert_eq!(crate::shortcuts::effective_shortcut(h.state(), "edit.copy", Some("Cmd+C")).as_deref(), Some("Cmd+C")); +} + +#[test] +fn a_system_key_is_refused() { + let (k, m) = if cfg!(target_os = "macos") { (Key::Tab, Modifiers::MAC_CMD | Modifiers::COMMAND) } else { (Key::F4, Modifiers::ALT) }; + let (mut h, id) = dialog(1.0, "edit.cut", ""); + capture_key(&mut h, id, k, m); + assert!(field(&h, id, "overrides").get("edit.cut").is_none(), "{}", field(&h, id, "overrides")); + assert!(field(&h, id, "message").as_str().is_some_and(|s| s.contains("reserved")), "{}", field(&h, id, "message")); + assert_eq!(field(&h, id, "messageWarning"), json!(true)); + assert!(crate::prefs_ui::reserved_shortcut(if cfg!(target_os = "macos") { "Cmd+Tab" } else { "Alt+F4" })); + assert!(!crate::prefs_ui::reserved_shortcut("Cmd+Shift+F4")); + assert!(!crate::prefs_ui::reserved_shortcut("garbage+")); +} + +/// The conflict describes the bindings as the capture left them: Reset All, Use Default or +/// Escape after it makes it stale, so Accept can't take a key nothing holds any more. +#[test] +fn a_conflict_goes_stale_when_the_bindings_change() { + for undo in ["Reset All to Defaults", "Use Default", "escape"] { + let (mut h, id) = dialog(1.0, "edit.cut", ""); + capture_key(&mut h, id, Key::C, Modifiers::COMMAND); + assert!(field(&h, id, "conflict").is_object(), "{undo}"); + if undo == "escape" { + h.state_mut().ui.dialog_mut(id).unwrap().fields.insert("capture".into(), json!(true)); + h.run_steps(2); + key(&mut h, Key::Escape, true, Modifiers::NONE); + h.run_steps(2); + key(&mut h, Key::Escape, false, Modifiers::NONE); + h.run_steps(2); + } else { + click(&mut h, undo); + } + assert!(field(&h, id, "conflict").is_null(), "{undo}: {}", field(&h, id, "conflict")); + assert!(h.query_by_label("Accept and Go to Conflict").is_none(), "{undo}"); + crate::dialogs::confirm(h.state_mut(), id).unwrap(); + if undo != "escape" { + assert_eq!(crate::shortcuts::effective_shortcut(h.state(), "edit.copy", Some("Cmd+C")).as_deref(), Some("Cmd+C"), "{undo}: Copy keeps ⌘C"); + } + } +} + +#[cfg(all(unix, not(target_os = "macos")))] +#[test] +fn linux_reserves_its_own_system_keys() { + assert!(crate::prefs_ui::reserved_shortcut("Cmd+Alt+F2")); + assert!(!crate::prefs_ui::reserved_shortcut("Cmd+Escape")); +} + +#[cfg(target_os = "windows")] +#[test] +fn windows_reserves_its_own_system_keys() { + assert!(crate::prefs_ui::reserved_shortcut("Cmd+Escape")); + assert!(!crate::prefs_ui::reserved_shortcut("Cmd+Alt+F2")); +} + +/// A key two commands hold (Cut's ⌘X, given to Copy too): Accept takes it from both. +#[test] +fn accept_takes_the_key_from_every_command_holding_it() { + let (mut h, id) = dialog(1.0, "edit.paste", ""); + h.state_mut().ui.dialog_mut(id).unwrap().fields.insert("overrides".into(), json!({"edit.copy": "Cmd+X"})); + h.run_steps(2); + capture_key(&mut h, id, Key::X, Modifiers::COMMAND); + let holders = field(&h, id, "conflict")["holders"].clone(); + assert!(holders.as_array().is_some_and(|a| a.len() == 2), "{holders}"); + click(&mut h, "Accept and Go to Conflict"); + let o = field(&h, id, "overrides"); + assert_eq!((o["edit.cut"].clone(), o["edit.copy"].clone(), o["edit.paste"].clone()), (json!(""), json!(""), json!("Cmd+X")), "{o}"); +} From 54f4baf6f96fd42a5ad0873fc162b862cc7b7a4f Mon Sep 17 00:00:00 2001 From: Tanish Manku Date: Thu, 15 Oct 2026 14:33:51 -0700 Subject: [PATCH 33/47] Layers panel: blend mode, Opacity, Fill and locks change every selected layer; Photoshop's History names (#5856) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * Layers panel: blend mode, Opacity, Fill and locks change every selected layer; Photoshop's History names With several layers selected, the Layers panel's blend mode, Opacity, Fill and lock controls changed only the active layer, silently (the layers audit's top panel gap). They now change every selected layer, as in Photoshop, in one undo step. layer.setProps takes "layers": [id, …] for that (headless too); a list with an unknown id, a bad shape, or a name for several layers is an error that changes nothing. History names follow Photoshop for a single kind of change: Opacity Change, Fill Opacity Change, Blending Change, Rename Layer (a mix stays Layer Properties). * Layers panel with several selected: Pass Through only for groups (refused for other layers), the Basic lock toggle and the blend-mode hover preview take every selected layer, the controls grey with the Background selected; History icon for opacity/fill/blend changes * Layers panel: leave the Background-in-a-selection behaviour as it was until Photoshop's is confirmed (#5856) * Layers panel: a selected Background is left out of several-layer Opacity, Fill, blend and lock changes until Photoshop's behaviour is confirmed (#5856) --- crates/engine/src/commands.rs | 119 ++++++++++++++------- crates/engine/tests/layer_props_several.rs | 72 +++++++++++++ crates/ui-egui/src/blend_preview.rs | 46 +++++--- crates/ui-egui/src/blend_preview/tests.rs | 24 ++++- crates/ui-egui/src/layer_props_ui_tests.rs | 78 ++++++++++++++ crates/ui-egui/src/lib.rs | 2 + crates/ui-egui/src/panels.rs | 52 ++++++--- 7 files changed, 319 insertions(+), 74 deletions(-) create mode 100644 crates/engine/tests/layer_props_several.rs create mode 100644 crates/ui-egui/src/layer_props_ui_tests.rs diff --git a/crates/engine/src/commands.rs b/crates/engine/src/commands.rs index edb60b06dc..2f04187128 100644 --- a/crates/engine/src/commands.rs +++ b/crates/engine/src/commands.rs @@ -741,10 +741,32 @@ fn build() -> Vec { "Layer Properties", [], None, - r##"{"layer":id?,"name":str?,"visible":bool?,"opacity":0..1?,"fill":0..1?,"blend":"Multiply|…"?,"clipped":bool?,"locked":bool?,"locks":{"transparency","pixels","position","artboard","all":bool}?,"channels":[bool,…]? (Advanced Blending: which colour channels blend, R G B / C M Y K / L a b)}"##, + r##"{"layer":id?,"layers":[id,…]? (the same change to each, one step: the Layers panel's controls with several layers selected),"name":str? (one layer only),"visible":bool?,"opacity":0..1?,"fill":0..1?,"blend":"Multiply|…"?,"clipped":bool?,"locked":bool?,"locks":{"transparency","pixels","position","artboard","all":bool}?,"channels":[bool,…]? (Advanced Blending: which colour channels blend, R G B / C M Y K / L a b)}"##, has_layer, |s, p| { - let id = layer_param(s, p)?; + // Several layers at once (the Layers panel with several selected, as in Photoshop), + // else the given or active one. + let ids: Vec = match p.get("layers") { + None | Some(Value::Null) => vec![layer_param(s, p)?], + Some(Value::Array(a)) if !a.is_empty() && a.len() <= 10_000 => { + let doc = &s.active().ok_or(EngineError::NoDocument)?.doc; + let mut ids = Vec::with_capacity(a.len()); + for v in a { + let id = v.as_u64().map(LayerId).ok_or_else(|| bad("layer.setProps", format!("`layers` holds layer ids, got {v}")))?; + if doc.layer(id).is_none() { + return Err(EngineError::NoLayer(id)); + } + if !ids.contains(&id) { + ids.push(id); + } + } + ids + } + Some(v) => return Err(bad("layer.setProps", format!("`layers` is a non-empty list of layer ids, got {v}"))), + }; + if ids.len() > 1 && p.get("name").is_some() { + return Err(bad("layer.setProps", "`name` renames one layer, not several")); + } // Opacity and Fill are fractions here, while the Layer Style commands take // percentages: a value outside 0..1 is refused, never clamped to 100 % (#5242). let unit = |key: &str| -> Result> { @@ -755,9 +777,19 @@ fn build() -> Vec { } }; let (opacity, fill) = (unit("opacity")?, unit("fill")?); - let only_visibility = - p.as_object().is_some_and(|o| o.contains_key("visible") && o.keys().all(|k| matches!(k.as_str(), "layer" | "visible" | "coalesce"))); - let label = if only_visibility { "Layer Visibility" } else { "Layer Properties" }; + // Photoshop's History names for a single kind of change; a mix is "Layer Properties". + let changed: Vec<&str> = p + .as_object() + .map(|o| o.keys().map(String::as_str).filter(|k| !matches!(*k, "layer" | "layers" | "coalesce")).collect()) + .unwrap_or_default(); + let label = match changed.as_slice() { + ["visible"] => "Layer Visibility", + ["opacity"] => "Opacity Change", + ["fill"] => "Fill Opacity Change", + ["blend"] => "Blending Change", + ["name"] => "Rename Layer", + _ => "Layer Properties", + }; let before = s.active().ok_or(EngineError::NoDocument)?.doc.clone(); // Clipping and channel changes reach the layers around it: recomposite everything. let local = p.get("clipped").is_none() && p.get("channels").is_none(); @@ -766,47 +798,54 @@ fn build() -> Vec { // one that changes its look turns it into a normal layer ("Layer 0") first. let num = |k: &str| p.get(k).and_then(Value::as_f64).is_some_and(|v| v < 1.0); let blends = p.get("blend").and_then(Value::as_str).and_then(blend_from_str).is_some_and(|b| b != photocraft_color::BlendMode::Normal); - if num("opacity") || num("fill") || blends { - crate::extra_cmds::background_to_layer(doc, id); - } - let l = doc.layer_mut(id).ok_or(EngineError::NoLayer(id))?; - if let Some(v) = p.get("name").and_then(Value::as_str) { - l.name = v.to_string(); - } - if let Some(v) = p.get("visible").and_then(Value::as_bool) { - l.visible = v; - } - if let Some(v) = opacity { - l.opacity = v; - } - if let Some(v) = fill { - l.fill_opacity = v; - } - if let Some(v) = p.get("blend").and_then(Value::as_str) { - l.blend = blend_from_str(v).ok_or_else(|| bad("layer.setProps", format!("unknown blend mode `{v}`")))?; - } - if let Some(v) = p.get("clipped").and_then(Value::as_bool) { - l.clipped = v; - } - if let Some(v) = p.get("locked").and_then(Value::as_bool) { - l.locks.all = v; - } - if let Some(Value::Array(ch)) = p.get("channels") { - // Blending Options › Advanced Blending › Channels: unchecked = left out. - l.excluded_channels = ch.iter().take(32).enumerate().filter(|(_, v)| v.as_bool() == Some(false)).fold(0, |m, (i, _)| m | 1 << i); - } - if let Some(Value::Object(m)) = p.get("locks") { - for (k, v) in m { - let v = v.as_bool().unwrap_or(false); - if !l.locks.set(k, v) { - return Err(bad("layer.setProps", format!("unknown lock `{k}`"))); + for &id in &ids { + if num("opacity") || num("fill") || blends { + crate::extra_cmds::background_to_layer(doc, id); + } + let l = doc.layer_mut(id).ok_or(EngineError::NoLayer(id))?; + if let Some(v) = p.get("name").and_then(Value::as_str) { + l.name = v.to_string(); + } + if let Some(v) = p.get("visible").and_then(Value::as_bool) { + l.visible = v; + } + if let Some(v) = opacity { + l.opacity = v; + } + if let Some(v) = fill { + l.fill_opacity = v; + } + if let Some(v) = p.get("blend").and_then(Value::as_str) { + let b = blend_from_str(v).ok_or_else(|| bad("layer.setProps", format!("unknown blend mode `{v}`")))?; + // Pass Through is a group's mode only, as in Photoshop. + if b == photocraft_color::BlendMode::PassThrough && !l.is_group() { + return Err(bad("layer.setProps", format!("Pass Through applies to groups only; layer {} isn't one", l.id.0))); + } + l.blend = b; + } + if let Some(v) = p.get("clipped").and_then(Value::as_bool) { + l.clipped = v; + } + if let Some(v) = p.get("locked").and_then(Value::as_bool) { + l.locks.all = v; + } + if let Some(Value::Array(ch)) = p.get("channels") { + // Blending Options › Advanced Blending › Channels: unchecked = left out. + l.excluded_channels = ch.iter().take(32).enumerate().filter(|(_, v)| v.as_bool() == Some(false)).fold(0, |m, (i, _)| m | 1 << i); + } + if let Some(Value::Object(m)) = p.get("locks") { + for (k, v) in m { + let v = v.as_bool().unwrap_or(false); + if !l.locks.set(k, v) { + return Err(bad("layer.setProps", format!("unknown lock `{k}`"))); + } } } } Ok(()) })?; if local { - crate::layer_multi_cmds::note_damage(s, &before, &[id]); + crate::layer_multi_cmds::note_damage(s, &before, &ids); } Ok(Value::Null) } diff --git a/crates/engine/tests/layer_props_several.rs b/crates/engine/tests/layer_props_several.rs new file mode 100644 index 0000000000..beee852d1c --- /dev/null +++ b/crates/engine/tests/layer_props_several.rs @@ -0,0 +1,72 @@ +//! `layer.setProps` on several layers at once (the Layers panel with several selected, as in +//! Photoshop), and Photoshop's History names for each kind of change. + +use photocraft_engine::Session; +use serde_json::json; + +fn doc() -> (Session, Vec) { + let mut s = Session::new(); + s.execute("file.new", json!({"width": 32, "height": 32, "background": "white"})).unwrap(); + let ids = (0..3).map(|_| s.execute("layer.new.layer", json!({})).unwrap()["layer"].as_u64().unwrap()).collect(); + (s, ids) +} + +fn layer(s: &Session, id: u64) -> photocraft_doc::Layer { + s.active().unwrap().doc.layer(photocraft_doc::LayerId(id)).unwrap().clone() +} + +fn last_step(s: &Session) -> String { + s.active().unwrap().history.entries().last().cloned().unwrap_or_default() +} + +#[test] +fn one_change_reaches_every_listed_layer_in_one_step() { + let (mut s, ids) = doc(); + let steps = s.active().unwrap().history.entries().len(); + s.execute("layer.setProps", json!({"layers": [ids[0], ids[2]], "opacity": 0.4})).unwrap(); + assert_eq!(s.active().unwrap().history.entries().len(), steps + 1, "one step"); + assert_eq!(last_step(&s), "Opacity Change"); + assert_eq!((layer(&s, ids[0]).opacity, layer(&s, ids[1]).opacity, layer(&s, ids[2]).opacity), (0.4, 1.0, 0.4)); + s.execute("layer.setProps", json!({"layers": ids, "blend": "Multiply"})).unwrap(); + assert_eq!(last_step(&s), "Blending Change"); + assert!(ids.iter().all(|id| layer(&s, *id).blend == photocraft_color::BlendMode::Multiply)); + s.execute("layer.setProps", json!({"layers": [ids[1], ids[1], ids[2]], "locks": {"all": true}})).unwrap(); + assert!(layer(&s, ids[1]).locks.all && layer(&s, ids[2]).locks.all && !layer(&s, ids[0]).locks.all, "duplicates are harmless"); + s.execute("layer.setProps", json!({"layers": [ids[0]], "fill": 0.5})).unwrap(); + assert_eq!(last_step(&s), "Fill Opacity Change"); + s.execute("layer.setProps", json!({"layer": ids[0], "name": "Sky"})).unwrap(); + assert_eq!(last_step(&s), "Rename Layer"); + s.execute("layer.setProps", json!({"layer": ids[0], "opacity": 0.3, "fill": 0.3})).unwrap(); + assert_eq!(last_step(&s), "Layer Properties", "a mix keeps the general name"); +} + +#[test] +fn bad_layer_lists_change_nothing() { + let (mut s, ids) = doc(); + let before = s.active().unwrap().doc.clone(); + for p in [ + json!({"layers": [], "opacity": 0.5}), + json!({"layers": "all", "opacity": 0.5}), + json!({"layers": ["x"], "opacity": 0.5}), + json!({"layers": [ids[0], 999_999], "opacity": 0.5}), + json!({"layers": [ids[0], ids[1]], "name": "Both"}), + json!({"layers": [ids[0], ids[1]], "opacity": 2.0}), + ] { + assert!(s.execute("layer.setProps", p.clone()).is_err(), "{p}"); + assert_eq!(s.active().unwrap().doc.layers, before.layers, "{p}"); + } +} + +#[test] +fn pass_through_is_for_groups_only() { + let (mut s, ids) = doc(); + let g = s.execute("layer.new.group", json!({})).unwrap()["layer"].as_u64().unwrap(); + // A pixel layer, alone or with a group, can't take Pass Through; nothing changes. + assert!(s.execute("layer.setProps", json!({"layer": ids[0], "blend": "Pass Through"})).is_err()); + let before = (layer(&s, ids[0]).blend, layer(&s, g).blend); + assert!(s.execute("layer.setProps", json!({"layers": [g, ids[0]], "blend": "Pass Through"})).is_err()); + assert_eq!((layer(&s, ids[0]).blend, layer(&s, g).blend), before); + s.execute("layer.setProps", json!({"layers": [g], "blend": "Normal"})).unwrap(); + s.execute("layer.setProps", json!({"layer": g, "blend": "Pass Through"})).unwrap(); + assert_eq!(layer(&s, g).blend, photocraft_color::BlendMode::PassThrough); +} diff --git a/crates/ui-egui/src/blend_preview.rs b/crates/ui-egui/src/blend_preview.rs index e9f010613a..a466e2f375 100644 --- a/crates/ui-egui/src/blend_preview.rs +++ b/crates/ui-egui/src/blend_preview.rs @@ -1,5 +1,5 @@ -//! Layers panel: hovering a mode in the open blend-mode list previews it on the active layer -//! (Photoshop's live blend-mode preview, #970). Hovering records nothing: leaving the list, or +//! Layers panel: hovering a mode in the open blend-mode list previews it on the layers it would +//! change, the active one or every selected one (Photoshop's live blend-mode preview, #970). Hovering records nothing: leaving the list, or //! closing it without a choice, shows the document again, and choosing a mode commits it through //! `layer.setProps` (one history step). The canvas re-renders once per hovered mode, and only //! where the layer's blending can change pixels. @@ -15,17 +15,17 @@ use crate::PhotocraftApp; /// Preview keys (`canvas::display_doc`) are `BASE` plus a hash of the revision and the mode. const BASE: u64 = 1 << 37; -/// A blend mode hovered for a layer of a document at a revision. +/// A blend mode hovered for layers of a document at a revision. pub(crate) struct BlendPreview { doc: DocId, revision: u64, - layer: LayerId, + layers: Vec, /// The mode under the pointer; `None` once it left the list (the rest stays for the /// canvas's damage on its way back to the document). mode: Option, /// The frame the list was last drawn in: a list no longer drawn previews nothing. frame: u64, - /// Where the layer's blending can change pixels; `None` for anywhere. + /// Where the layers' blending can change pixels; `None` for anywhere. area: Option, /// The document shown for `mode`, with its key. shown: Option<(u64, Arc)>, @@ -33,11 +33,11 @@ pub(crate) struct BlendPreview { /// The Layers panel, each frame it draws `layer`'s blend-mode dropdown: the mode under the /// pointer in its open list, if any. -pub(crate) fn hover(app: &mut PhotocraftApp, layer: LayerId, mode: Option) { +pub(crate) fn hover(app: &mut PhotocraftApp, layers: &[LayerId], mode: Option) { let frame = app.frame; let Some(st) = app.session.active() else { return }; let (doc, revision) = (st.doc.id, st.revision); - if let Some(p) = app.blend_preview.as_mut().filter(|p| p.doc == doc && p.revision == revision && p.layer == layer) { + if let Some(p) = app.blend_preview.as_mut().filter(|p| p.doc == doc && p.revision == revision && p.layers == layers) { p.frame = frame; if mode.is_none() { p.shown = None; @@ -46,8 +46,15 @@ pub(crate) fn hover(app: &mut PhotocraftApp, layer: LayerId, mode: Option = Some(Rect::EMPTY); + for &id in layers { + area = match (area, photocraft_compose::change_bounds(st.doc.layer(id)?, st.doc.bounds())) { + (Some(a), Some(b)) => Some(if a.is_empty() { b } else { a.union(&b) }), + _ => None, + }; + } + Some(BlendPreview { doc, revision, layers: layers.to_vec(), mode: Some(mode), frame, area, shown: None }) }); } @@ -57,15 +64,23 @@ pub(crate) fn display_doc(app: &mut PhotocraftApp, idx: usize) -> Option<(Arc= frame)?; let mode = p.mode?; - let key = preview_key(p.revision, p.layer, mode); + let key = preview_key(p.revision, &p.layers, mode); if let Some((k, shown)) = &p.shown && *k == key { return Some((shown.clone(), key)); } let mut doc = (*st.doc).clone(); - let l = doc.layer_mut(p.layer).filter(|l| l.blend != mode)?; - l.blend = mode; + let mut changed = false; + for &id in &p.layers { + if let Some(l) = doc.layer_mut(id).filter(|l| l.blend != mode) { + l.blend = mode; + changed = true; + } + } + if !changed { + return None; + } // Duotone documents display through their inks. let doc = Arc::new(photocraft_engine::mode_cmds::display_document(&doc).unwrap_or(doc)); p.shown = Some((key, doc.clone())); @@ -73,7 +88,7 @@ pub(crate) fn display_doc(app: &mut PhotocraftApp, idx: usize) -> Option<(Arc Option { let p = app.blend_preview.as_ref().filter(|p| p.doc == doc && p.revision == revision)?; @@ -85,8 +100,9 @@ fn is_preview_key(key: u64) -> bool { (BASE..BASE << 1).contains(&key) } -fn preview_key(revision: u64, layer: LayerId, mode: BlendMode) -> u64 { - let h = mode.label().bytes().fold(revision.wrapping_mul(0x9e37_79b9_7f4a_7c15) ^ layer.0, |h, b| h.wrapping_mul(31).wrapping_add(u64::from(b))); +fn preview_key(revision: u64, layers: &[LayerId], mode: BlendMode) -> u64 { + let seed = layers.iter().fold(revision.wrapping_mul(0x9e37_79b9_7f4a_7c15), |h, id| h.wrapping_mul(0x100_0000_01b3) ^ id.0); + let h = mode.label().bytes().fold(seed, |h, b| h.wrapping_mul(31).wrapping_add(u64::from(b))); (h & (BASE - 1)) | BASE } diff --git a/crates/ui-egui/src/blend_preview/tests.rs b/crates/ui-egui/src/blend_preview/tests.rs index 4eb407aad0..22908ac248 100644 --- a/crates/ui-egui/src/blend_preview/tests.rs +++ b/crates/ui-egui/src/blend_preview/tests.rs @@ -36,7 +36,7 @@ fn a_hovered_mode_shows_on_the_canvas_until_the_pointer_leaves() { let (s, top) = session(); let mut app = PhotocraftApp::new(s, crate::Services::default()); let (undo, revision) = (steps(&app), app.session.active().unwrap().revision); - hover(&mut app, top, Some(BlendMode::Multiply)); + hover(&mut app, &[top], Some(BlendMode::Multiply)); let (shown, key) = display_doc(&mut app, 0).expect("previewed"); assert_eq!(shown.layer(top).unwrap().blend, BlendMode::Multiply); let (again, same) = display_doc(&mut app, 0).unwrap(); @@ -45,17 +45,17 @@ fn a_hovered_mode_shows_on_the_canvas_until_the_pointer_leaves() { // From the document to the preview and between previews: only the layer's area redraws. let doc = app.session.active().unwrap().doc.id; assert_eq!(damage(&app, doc, revision, 0, key).map(|r| r.width()), Some(16)); - hover(&mut app, top, Some(BlendMode::Screen)); + hover(&mut app, &[top], Some(BlendMode::Screen)); let (_, screen) = display_doc(&mut app, 0).unwrap(); assert_ne!(screen, key); assert!(damage(&app, doc, revision, key, screen).is_some()); // The current mode, or no mode, shows the document; and so does a list no longer drawn. - hover(&mut app, top, Some(BlendMode::Normal)); + hover(&mut app, &[top], Some(BlendMode::Normal)); assert!(display_doc(&mut app, 0).is_none()); - hover(&mut app, top, None); + hover(&mut app, &[top], None); assert!(display_doc(&mut app, 0).is_none()); assert!(damage(&app, doc, revision, screen, 0).is_some(), "back to the document: the layer's area"); - hover(&mut app, top, Some(BlendMode::Multiply)); + hover(&mut app, &[top], Some(BlendMode::Multiply)); app.frame += 2; assert!(display_doc(&mut app, 0).is_none(), "a stale hover previews nothing"); } @@ -124,3 +124,17 @@ fn moving_between_modes_keeps_a_preview_across_the_gaps_between_them() { } assert_eq!(previewed(&mut h), Some(BlendMode::Multiply)); } + +#[test] +fn a_hovered_mode_previews_on_every_selected_layer() { + let (mut s, top) = session(); + let other = LayerId(s.execute("layer.new.layer", json!({})).unwrap()["layer"].as_u64().unwrap()); + s.execute("edit.fill", json!({"color": "#3366cc"})).unwrap(); + let mut app = PhotocraftApp::new(s, crate::Services::default()); + hover(&mut app, &[top, other], Some(BlendMode::Multiply)); + let (shown, key) = display_doc(&mut app, 0).expect("previewed"); + assert_eq!((shown.layer(top).unwrap().blend, shown.layer(other).unwrap().blend), (BlendMode::Multiply, BlendMode::Multiply)); + // A different set of layers is a different preview. + hover(&mut app, &[top], Some(BlendMode::Multiply)); + assert_ne!(display_doc(&mut app, 0).unwrap().1, key); +} diff --git a/crates/ui-egui/src/layer_props_ui_tests.rs b/crates/ui-egui/src/layer_props_ui_tests.rs new file mode 100644 index 0000000000..93055ee342 --- /dev/null +++ b/crates/ui-egui/src/layer_props_ui_tests.rs @@ -0,0 +1,78 @@ +//! The Layers panel's blend mode, Opacity, Fill and lock controls change every selected layer, +//! as in Photoshop, not only the active one. + +use egui_kittest::Harness; +use egui_kittest::kittest::Queryable; +use serde_json::json; + +use crate::PhotocraftApp; + +fn app() -> Harness<'static, PhotocraftApp> { + let mut h = Harness::builder().with_size(egui::vec2(1400.0, 900.0)).with_max_steps(64).build_eframe(|cc| { + PhotocraftApp::setup_context(&cc.egui_ctx, Default::default()); + let mut app = PhotocraftApp::new(photocraft_engine::Session::new(), crate::Services::default()); + app.run("file.new", json!({"width": 64, "height": 64, "background": "white"})).unwrap(); + app.run("layer.new.layer", json!({})).unwrap(); + app + }); + h.run_steps(6); + h +} + +/// The Layers panel's lock buttons change every selected layer (as do blend mode, Opacity and +/// Fill), not only the active one. +#[test] +fn the_layers_panel_locks_every_selected_layer() { + let mut h = app(); + let ids: Vec = h.state().session.active().unwrap().doc.layers.iter().map(|l| l.id.0).collect(); + let extra = h.state_mut().run("layer.new.layer", json!({})).unwrap()["layer"].as_u64().unwrap(); + h.state_mut().run("layer.select", json!({"layer": ids[1], "mode": "add"})).unwrap(); + h.run_steps(3); + assert_eq!(h.state().session.active().unwrap().selected_layers().len(), 2); + h.query_by_label_contains("Lock all").expect("the Lock all button").click(); + h.run_steps(3); + let st = h.state().session.active().unwrap(); + let locked = |id: u64| st.doc.layer(photocraft_doc::LayerId(id)).unwrap().locks.all; + assert!(locked(extra) && locked(ids[1]), "both selected layers lock"); + assert!(!locked(ids[0]), "the unselected Background doesn't"); +} + +/// The Basic layout's single lock toggle also locks every selected layer. +#[test] +fn the_basic_lock_toggle_locks_every_selected_layer() { + let mut h = Harness::builder().with_size(egui::vec2(1400.0, 900.0)).with_max_steps(64).build_eframe(|cc| { + PhotocraftApp::setup_context(&cc.egui_ctx, crate::theme::ThemeKind::Studio); + let mut app = PhotocraftApp::new(photocraft_engine::Session::new(), crate::Services::default()); + app.run("prefs.set", json!({"path": "interface.theme", "value": crate::theme::ThemeKind::Studio.id()})).unwrap(); + app.ui.theme = crate::theme::ThemeKind::Studio; + app.run("file.new", json!({"width": 64, "height": 64, "background": "white"})).unwrap(); + app + }); + let a = h.state_mut().run("layer.new.layer", json!({})).unwrap()["layer"].as_u64().unwrap(); + let b = h.state_mut().run("layer.new.layer", json!({})).unwrap()["layer"].as_u64().unwrap(); + h.state_mut().run("layer.select", json!({"layer": a, "mode": "add"})).unwrap(); + h.run_steps(4); + h.get_by_label("Lock layer").click(); + h.run_steps(3); + let st = h.state().session.active().unwrap(); + let locked = |id: u64| st.doc.layer(photocraft_doc::LayerId(id)).unwrap().locks.all; + assert!(locked(a) && locked(b), "both selected layers lock"); +} + +/// The Background, selected with other layers, is left out of their change (Opacity here) +/// until Photoshop's behaviour is confirmed (#5856): it stays the Background. +#[test] +fn a_selected_background_is_left_out_of_a_several_layer_change() { + let mut h = app(); + let st = h.state().session.active().unwrap(); + let (bg, top) = (st.doc.layers[0].id.0, st.active_layer.unwrap().0); + h.state_mut().run("layer.select", json!({"layer": bg})).unwrap(); + h.state_mut().run("layer.select", json!({"layer": top, "mode": "add"})).unwrap(); + h.run_steps(3); + h.get_by_label("Lock all").click(); + h.run_steps(3); + let st = h.state().session.active().unwrap(); + let l = |id: u64| st.doc.layer(photocraft_doc::LayerId(id)).unwrap().clone(); + assert!(l(top).locks.all, "the layer locks"); + assert!(st.doc.is_background(photocraft_doc::LayerId(bg)) && !l(bg).locks.all, "the Background is left as it was"); +} diff --git a/crates/ui-egui/src/lib.rs b/crates/ui-egui/src/lib.rs index 5d95f2d662..3a981c41f3 100644 --- a/crates/ui-egui/src/lib.rs +++ b/crates/ui-egui/src/lib.rs @@ -2638,6 +2638,8 @@ mod pixel_aspect_tests; #[cfg(test)] mod transform_fx_tests; +#[cfg(test)] +mod layer_props_ui_tests; #[cfg(test)] mod transform_undo_tests; diff --git a/crates/ui-egui/src/panels.rs b/crates/ui-egui/src/panels.rs index 522c74dc15..ed35a68804 100644 --- a/crates/ui-egui/src/panels.rs +++ b/crates/ui-egui/src/panels.rs @@ -1518,8 +1518,9 @@ const LAYER_PCT_W: f32 = 66.0 + widgets::POPUP_ARROW_W; /// The `layer.setProps` edit of a Layers panel Opacity or Fill (`key`) field at `pct` percent. A /// drag of the pop-up slider (`drag`) coalesces into one history step, as in Photoshop. -fn pct_action(l: &Layer, key: &str, pct: f32, drag: Option) -> (String, Value) { - let mut p = json!({"layer": l.id.0, key: pct / 100.0}); +fn pct_action(target: &Value, key: &str, pct: f32, drag: Option) -> (String, Value) { + let mut p = target.clone(); + p[key] = json!(pct / 100.0); if let Some(n) = drag { p["coalesce"] = json!(format!("layer-{key}-slider:{n}")); } @@ -2159,13 +2160,17 @@ fn hsb_sliders(app: &mut PhotocraftApp, ui: &mut egui::Ui) { /// any is set, and locks all otherwise. Toggling only "lock all" would leave the Background's /// transparency lock (still showing as locked), and a second click would lock its pixels, /// shutting out the Eraser and every other paint tool (#76). -fn simple_lock_toggle(background: bool, l: &Layer) -> (String, Value) { +/// The Basic layout's lock toggle: the active layer's state decides, and every target follows it +/// (`targets`: `{"layer": id}` or `{"layers": [ids]}`). +fn simple_lock_toggle(background: bool, l: &Layer, targets: &Value) -> (String, Value) { if background { return ("layer.new.layerFromBackground".into(), json!({})); } let locks = if l.locks.any() { json!({"transparency": false, "pixels": false, "position": false, "artboard": false, "all": false}) } else { json!({"all": true}) }; - ("layer.setProps".into(), json!({"layer": l.id.0, "locks": locks})) + let mut p = targets.clone(); + p["locks"] = locks; + ("layer.setProps".into(), p) } /// The Layers panel's blend modes: Photoshop's, plus the layer's own Paint.NET mode when it has one @@ -2231,8 +2236,21 @@ fn layers(app: &mut PhotocraftApp, ui: &mut egui::Ui) { ui.add_space(4.0); } if let Some(l) = active_layer { - // Photoshop greys blend mode, Opacity and Fill for the Background layer. + // With several layers selected, blend mode, Opacity, Fill and the locks change every one + // of them, as in Photoshop; the controls show the active layer's values. + // The Background stays out of a several-layer change until Photoshop's behaviour is + // confirmed (#5856): Opacity, Fill or a blend mode would turn it into Layer 0. + let ids: Vec = if selection.len() > 1 && selection.contains(&l.id) { + selection.iter().copied().filter(|id| *id == l.id || !doc.layer(*id).is_some_and(|x| crate::doc_props_ui::is_background(&doc, x))).collect() + } else { + vec![l.id] + }; + let targets = if ids.len() > 1 { json!({"layers": ids.iter().map(|id| id.0).collect::>()}) } else { json!({"layer": l.id.0}) }; + // Photoshop greys blend mode, Opacity and Fill for the Background layer. (With the + // Background among several selected, what Photoshop does awaits a check: #5856.) let bg = crate::doc_props_ui::is_background(&doc, l); + // Pass Through is a group's mode: offered only when every target is a group. + let all_groups = ids.iter().all(|id| doc.layer(*id).is_some_and(|x| x.is_group())); ui.horizontal(|ui| { ui.add_enabled_ui(!bg, |ui| { let mut m = l.blend; @@ -2242,13 +2260,15 @@ fn layers(app: &mut PhotocraftApp, ui: &mut egui::Ui) { let opacity_label = if t.pro { tl!("Opacity:") } else { tl!("Opacity") }; let (blend_width, show_opacity_label) = opacity_row_layout(ui.available_width(), body_text_width(ui, opacity_label), ui.spacing().item_spacing.x); - let (chosen, hovered) = widgets::dropdown_wheel_hovered(ui, "blend", &mut m, &blend_options(l.is_group(), l.blend), blend_width); + let (chosen, hovered) = widgets::dropdown_wheel_hovered(ui, "blend", &mut m, &blend_options(all_groups, l.blend), blend_width); // One step per choice: a click, an arrow key or each wheel notch (#1747). for m in &chosen { - actions.push(("layer.setProps".into(), json!({"layer": l.id.0, "blend": m.label()}))); + let mut p = targets.clone(); + p["blend"] = json!(m.label()); + actions.push(("layer.setProps".into(), p)); } // Hovering a mode previews it on the canvas (#970). - crate::blend_preview::hover(app, l.id, hovered.filter(|_| chosen.is_empty())); + crate::blend_preview::hover(app, &ids, hovered.filter(|_| chosen.is_empty())); ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| { let mut o = l.opacity * 100.0; let field = widgets::popup_value_field(ui, opacity_label, &mut o, 0.0..=100.0, "%", LAYER_PCT_W); @@ -2259,7 +2279,7 @@ fn layers(app: &mut PhotocraftApp, ui: &mut egui::Ui) { widgets::PopupFieldResponse::default() }; if field.changed || scrub.changed { - actions.push(pct_action(l, "opacity", o, scrub.drag.or(field.drag))); + actions.push(pct_action(&targets, "opacity", o, scrub.drag.or(field.drag))); } }); }); @@ -2295,7 +2315,9 @@ fn layers(app: &mut PhotocraftApp, ui: &mut egui::Ui) { // Lock Image Pixels includes Lock Transparent Pixels, which waits for it (#4892). let enabled = key != "transparency" || !lk.pixels; if ui.add_enabled_ui(enabled, |ui| icons::button(ui, icon, 20.0, on, tip)).inner.clicked() { - actions.push(("layer.setProps".into(), json!({"layer": l.id.0, "locks": { key: !on }}))); + let mut p = targets.clone(); + p["locks"] = json!({ key: !on }); + actions.push(("layer.setProps".into(), p)); } } }); @@ -2303,7 +2325,7 @@ fn layers(app: &mut PhotocraftApp, ui: &mut egui::Ui) { if !t.pro { let locked = l.locks.any(); if icons::button(ui, if locked { "lock" } else { "lock-open" }, 22.0, locked, tl!("Lock layer")).clicked() { - actions.push(simple_lock_toggle(bg, l)); + actions.push(simple_lock_toggle(crate::doc_props_ui::is_background(&doc, l), l, &targets)); } } ui.add_enabled_ui(!bg, |ui| { @@ -2312,7 +2334,7 @@ fn layers(app: &mut PhotocraftApp, ui: &mut egui::Ui) { let field = widgets::popup_value_field(ui, fill_label, &mut f, 0.0..=100.0, "%", LAYER_PCT_W); let scrub = scrub_pct_label(ui, fill_label, &mut f, ui.available_width()); if field.changed || scrub.changed { - actions.push(pct_action(l, "fill", f, scrub.drag.or(field.drag))); + actions.push(pct_action(&targets, "fill", f, scrub.drag.or(field.drag))); } }) }); @@ -2877,7 +2899,7 @@ fn layer_row( if let Some(&(_, lock)) = indicators.iter().find(|(k, _)| *k == crate::layer_row_ui::Indicator::Lock) && ui.interact(lock, ui.id().with(("row-lock", l.id.0)), Sense::click()).clicked() { - actions.push(simple_lock_toggle(doc.is_background(l.id), l)); + actions.push(simple_lock_toggle(doc.is_background(l.id), l, &json!({"layer": l.id.0}))); } let name_color = if l.visible { t.text } else { t.text_faint }; let font = if selected && !t.pro { theme::medium(13.0) } else { egui::FontId::proportional(if t.pro { 12.0 } else { 13.0 }) }; @@ -3139,6 +3161,8 @@ fn history(app: &mut PhotocraftApp, ui: &mut egui::Ui) { } let icon = match e.as_str() { s if s.contains("Brush") || s.contains("Eraser") => "brush", + // The Layers panel's controls (layer.setProps). + "Opacity Change" | "Fill Opacity Change" | "Blending Change" => "layers", s if s.contains("Layer") => "layers", s if s.contains("Select") || s.contains("Marquee") || s.contains("Deselect") || s.contains("Lasso") => "square-dashed", s if s.contains("Fill") => "paint-bucket", @@ -4992,7 +5016,7 @@ mod lock_tests { fn click_lock(app: &mut PhotocraftApp) { let st = app.session.active().unwrap(); let l = st.active_layer.and_then(|id| st.doc.layer(id)).unwrap(); - let (cmd, p) = simple_lock_toggle(crate::doc_props_ui::is_background(&st.doc, l), l); + let (cmd, p) = simple_lock_toggle(crate::doc_props_ui::is_background(&st.doc, l), l, &json!({"layer": l.id.0})); app.run(&cmd, p).unwrap(); } From b09aeae2e2d149f62a4e261250cd0cc129d1fb0b Mon Sep 17 00:00:00 2001 From: Tanish Manku Date: Thu, 15 Oct 2026 15:23:47 -0700 Subject: [PATCH 34/47] Low-Latency Drawing preference (vsync off, opt-in), plus present-mode overrides for testers (#1343) (#5274) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * PHOTOCRAFT_PRESENT_MODE / PHOTOCRAFT_FRAME_LATENCY launch overrides for latency A/B tests (#1343) Linux traces put the remaining stroke latency in presentation queueing (the app's frame fits in one refresh; the rest waits on the swap chain), and an NVIDIA user cured it by forcing vsync off. Testers can now compare without rebuilding: PHOTOCRAFT_PRESENT_MODE (fifo, mailbox, immediate, autovsync, autonovsync) and PHOTOCRAFT_FRAME_LATENCY (1-3), applied over the platform default. Bad values are logged and ignored. The active mode is logged at start, kept in GpuInfo.present and shown in every stroke trace line. RTX 5090, Vulkan, 120 Hz: default autovsync/latency 1 waits 6 ms per 8.3 ms frame; mailbox renders every 2.3 ms with 0.2 ms wait, so each vsync shows the newest frame. * docs: present mode and frame latency overrides * Present mode overrides never request a mode the driver may lack (Mailbox crashed on X11/NVIDIA) * Preferences › Performance › Low-Latency Drawing, on by default: no vsync wait while drawing (#1343) Testers' traces put the ~50 ms stroke lag in the swap chain's vsync wait. With vsync off it disappears (X11/NVIDIA; Windows/NVIDIA via the driver), and a shorter queue doesn't help. The surface now presents with AutoNoVsync by default: wgpu picks Immediate, else Mailbox, else Fifo by what the driver offers, so no unsupported mode is ever requested. Turning the preference off brings vsync back (smooth, but late). It applies at the next launch, like the other graphics settings. Where only Immediate exists the canvas may tear while it changes; frames render as fast as input arrives during a stroke and stop when idle. * Runtime and launch controls: ui.set strokeTrace, --stroke-trace, --present-mode, --frame-latency The retro's remaining gap: the stroke trace and the presentation overrides were env-only. The trace now switches at runtime over the control channel (ui.set {"strokeTrace": true|false|"stderr"|path}) and with --stroke-trace; --present-mode / --frame-latency sit beside their env vars and win over them. Bad values are reported and ignored; none fails a launch. * docs: strokeTrace beside the other ui.set fields * docs: regenerate the scorecard after rebasing onto main * Low-Latency Drawing: frames capped at 240 fps without vsync (after #4472); ui.set strokeTrace takes on/off only (a CSV path is launch-only); tearing documented as usual (Immediate); pref hidden on web; doc comment order * stroke_trace: from_value doc says ui.set passes only on or off * Low-Latency Drawing ships off (opt-in), per the owner's decision * docs: present mode default is vsync (Low-Latency Drawing ships off) --- apps/photocraft/src/gpu_startup.rs | 25 ++++++ apps/photocraft/src/main.rs | 42 +++++++++- apps/photocraft/src/presentation.rs | 121 ++++++++++++++++++++++++++++ crates/engine/src/prefs.rs | 8 ++ crates/ui-egui/src/control.rs | 39 ++++++++- crates/ui-egui/src/gpu_canvas.rs | 3 + crates/ui-egui/src/i18n/cs.tsv | 1 + crates/ui-egui/src/i18n/de.tsv | 1 + crates/ui-egui/src/i18n/el.tsv | 1 + crates/ui-egui/src/i18n/es.tsv | 1 + crates/ui-egui/src/i18n/fr.tsv | 1 + crates/ui-egui/src/i18n/hu.tsv | 1 + crates/ui-egui/src/i18n/id.tsv | 1 + crates/ui-egui/src/i18n/it.tsv | 1 + crates/ui-egui/src/i18n/ja.tsv | 1 + crates/ui-egui/src/i18n/ko.tsv | 1 + crates/ui-egui/src/i18n/nl.tsv | 1 + crates/ui-egui/src/i18n/pl.tsv | 1 + crates/ui-egui/src/i18n/pt-br.tsv | 1 + crates/ui-egui/src/i18n/ru.tsv | 1 + crates/ui-egui/src/i18n/sr.tsv | 1 + crates/ui-egui/src/i18n/tr.tsv | 1 + crates/ui-egui/src/i18n/uk.tsv | 1 + crates/ui-egui/src/i18n/vi.tsv | 1 + crates/ui-egui/src/i18n/zh-hans.tsv | 1 + crates/ui-egui/src/i18n/zh-hant.tsv | 1 + crates/ui-egui/src/lib.rs | 28 ++++++- crates/ui-egui/src/prefs_ui.rs | 7 ++ crates/ui-egui/src/stroke_trace.rs | 38 ++++++++- docs/control-protocol.md | 1 + docs/development.md | 6 +- docs/scorecard.md | 4 +- 32 files changed, 332 insertions(+), 10 deletions(-) create mode 100644 apps/photocraft/src/presentation.rs diff --git a/apps/photocraft/src/gpu_startup.rs b/apps/photocraft/src/gpu_startup.rs index 66acb9a42f..e79b4bafac 100644 --- a/apps/photocraft/src/gpu_startup.rs +++ b/apps/photocraft/src/gpu_startup.rs @@ -443,6 +443,14 @@ pub fn read_display_server(path: Option<&Path>) -> photocraft_engine::prefs::Lin .unwrap_or_default() } +/// Preferences › Performance › Low-Latency Drawing (`performance.lowLatencyDrawing`, off unless +/// turned on), read before the window opens: it picks the surface's present mode. +pub fn read_low_latency(path: Option<&Path>) -> bool { + let v: Value = path.and_then(|p| std::fs::read_to_string(p).ok()).and_then(|t| serde_json::from_str(&t).ok()).unwrap_or(Value::Null); + let default = photocraft_engine::prefs::Preferences::default().performance.low_latency_drawing; + v.get("performance").and_then(|p| p.get("lowLatencyDrawing")).and_then(Value::as_bool).unwrap_or(default) +} + /// Read the policy leniently at startup, including old settings without renderingMode. pub fn read_rendering_prefs(path: Option<&Path>) -> (GpuBackend, RenderingMode) { let v: Value = path.and_then(|p| std::fs::read_to_string(p).ok()).and_then(|t| serde_json::from_str(&t).ok()).unwrap_or(Value::Null); @@ -463,6 +471,23 @@ mod tests { Marker { backend: backend.into(), adapter: "Intel(R) UHD Graphics".into(), adapter_backend: adapter_backend.into(), ..Default::default() } } + #[test] + fn low_latency_drawing_is_read_before_the_window_opens_and_defaults_off() { + let dir = std::env::temp_dir().join(format!("photocraft-low-latency-{}", std::process::id())); + std::fs::create_dir_all(&dir).unwrap(); + let file = dir.join("preferences.json"); + let read = |json: &str| { + std::fs::write(&file, json).unwrap(); + read_low_latency(Some(&file)) + }; + assert!(read(r#"{"performance":{"lowLatencyDrawing":true}}"#)); + assert!(!read(r#"{"performance":{"lowLatencyDrawing":false}}"#)); + assert!(!read(r#"{"performance":{"lowLatencyDrawing":"nope"}}"#), "garbage keeps the default (off)"); + assert!(!read("not json")); + assert!(!read_low_latency(None), "no settings file: the default (off)"); + let _ = std::fs::remove_dir_all(&dir); + } + #[test] fn display_server_preference_is_read_before_the_window_opens() { use photocraft_engine::prefs::LinuxDisplayServer; diff --git a/apps/photocraft/src/main.rs b/apps/photocraft/src/main.rs index de1155edbd..0b82237a39 100644 --- a/apps/photocraft/src/main.rs +++ b/apps/photocraft/src/main.rs @@ -2,7 +2,13 @@ //! //! Usage: `photocraft [--control ] [--control-token <64-hex> | //! --control-token-file ] [--automation-read-root ] -//! [--automation-write-root ] [--safe-gpu] [--in-window-menus] [files…]` +//! [--automation-write-root ] [--safe-gpu] [--in-window-menus] [--present-mode ] +//! [--frame-latency ] [--stroke-trace ] [files…]` +//! +//! `--present-mode` (`fifo`, `autovsync`, `novsync`) and `--frame-latency` (1–3) override how +//! frames reach the display for this launch (`PHOTOCRAFT_PRESENT_MODE` / `…_FRAME_LATENCY`; +//! `presentation`); `--stroke-trace` times each brush stroke's frames (`PHOTOCRAFT_STROKE_TRACE`; +//! `photocraft_ui_egui::stroke_trace`). //! //! `--safe-gpu` starts with the CPU renderer (no GPU canvas; a software adapter for the window //! where the platform has one) for this launch, e.g. after a graphics driver crash. A start that @@ -44,6 +50,7 @@ mod linux_libs; mod logging; mod monitor_profile; mod photoshop_settings; +mod presentation; mod screen_color; mod services; // Windows gets pen pressure from winit (WM_POINTER); the web runner has its own listener. @@ -191,6 +198,7 @@ fn main() -> eframe::Result { // reported in a notice once the app is up; a control token is a usage error like a bad port. let mut unreadable_paths: Vec = Vec::new(); let mut safe_gpu = false; + let (mut present_mode_arg, mut frame_latency_arg, mut stroke_trace_arg): (Option, Option, Option) = (None, None, None); let mut in_window_menus = std::env::var_os("PHOTOCRAFT_IN_WINDOW_MENUS").is_some_and(|v| !v.is_empty() && v != "0"); let mut args = std::env::args_os().skip(1); while let Some(a) = args.next() { @@ -211,6 +219,18 @@ fn main() -> eframe::Result { Some("--automation-read-root") => automation_read_root = args.next().map(std::path::PathBuf::from), Some("--automation-write-root") => automation_write_root = args.next().map(std::path::PathBuf::from), Some("--safe-gpu") => safe_gpu = true, + Some(flag @ ("--present-mode" | "--frame-latency" | "--stroke-trace")) => { + let flag = flag.to_string(); + match args.next().map(std::ffi::OsString::into_string) { + Some(Ok(v)) => match flag.as_str() { + "--present-mode" => present_mode_arg = Some(v), + "--frame-latency" => frame_latency_arg = Some(v), + _ => stroke_trace_arg = Some(v), + }, + // A bad value is reported and ignored; it never fails the launch. + _ => eprintln!("photocraft: {flag}: missing or unreadable value; ignored"), + } + } Some("--in-window-menus") => in_window_menus = true, Some("--version") => { println!("photocraft {}", photocraft_engine::build_info::long_version()); @@ -364,6 +384,21 @@ fn main() -> eframe::Result { let gpu_note: std::sync::Arc>> = Default::default(); // The adapter's real texture limits (egui asks for 8192 px), so big documents stay on the GPU. gpu_startup::configure(&mut options.wgpu_options.wgpu_setup, &plan, os, sentinel.clone(), gpu_note.clone()); + // Preferences › Performance › Low-Latency Drawing (#1343): without vsync a stroke shows about + // 50 ms sooner; wgpu picks Immediate, else Mailbox, else Fifo by what the driver offers. + if gpu_startup::read_low_latency(services::prefs_file().as_deref()) { + options.wgpu_options.surface.present_mode = eframe::wgpu::PresentMode::AutoNoVsync; + } + // Testers' present mode and frame latency overrides (#1343), after the preference. + let present = presentation::Overrides::from_args_or_env(present_mode_arg.as_deref(), frame_latency_arg.as_deref()); + options.wgpu_options.surface = present.apply(options.wgpu_options.surface); + let present_desc = presentation::describe(&options.wgpu_options.surface, &present); + // Without vsync, frames are capped at 240 fps (#4472) so repaint loops don't spin flat out. + let uncapped = !matches!( + options.wgpu_options.surface.present_mode, + eframe::wgpu::PresentMode::Fifo | eframe::wgpu::PresentMode::FifoRelaxed | eframe::wgpu::PresentMode::AutoVsync + ); + log::info!("presentation: {present_desc}"); let sentinel_ms = t_sentinel.elapsed().as_secs_f64() * 1000.0; log::info!("GPU startup: {:?} ({sentinel_ms:.2} ms)", plan); let retry_cpu = !safe_gpu && plan.backend != photocraft_engine::prefs::GpuBackend::Cpu; @@ -392,6 +427,7 @@ fn main() -> eframe::Result { } } let mut app = PhotocraftApp::new(Session::new(), services); + app.min_frame_ms = uncapped.then_some(1000.0 / 240.0); app.integrated_titlebar = cfg!(target_os = "macos"); app.custom_titlebar = custom_titlebar; // Only the title bar's free gap drags the window, never the menus (mac_window.rs). @@ -423,6 +459,10 @@ fn main() -> eframe::Result { // composites on the CPU). let info = &mut app.perf.gpu_info; info.preference = pref.name().to_string(); + info.present = present_desc.clone(); + if let Some(v) = &stroke_trace_arg { + app.stroke_trace.set(v); + } info.selected = if plan.env.is_some() { "env".into() } else { plan.backend.name().to_string() }; info.fallback = match (plan.reason.clone(), gpu_note.lock().unwrap_or_else(std::sync::PoisonError::into_inner).clone()) { (Some(reason), Some(note)) => Some(format!("{reason}. {note}")), diff --git a/apps/photocraft/src/presentation.rs b/apps/photocraft/src/presentation.rs new file mode 100644 index 0000000000..c6f7e4196a --- /dev/null +++ b/apps/photocraft/src/presentation.rs @@ -0,0 +1,121 @@ +//! Launch overrides for how frames reach the display, so testers can compare input-to-pixel +//! latency without a rebuild (#1343): `PHOTOCRAFT_PRESENT_MODE` (`fifo`, `autovsync`, or `novsync`, +//! also spelled `immediate`, `mailbox` or `autonovsync`: no vsync, wgpu's Immediate → Mailbox → Fifo +//! by what the driver offers) and `PHOTOCRAFT_FRAME_LATENCY` (`1` to `3`, frames the swap chain may +//! queue). Unset, the platform default applies. A value that isn't one of these is ignored with a +//! warning in the log, never a failed start; a mode the driver may not offer is never requested. +//! The result is logged at start and shown in the stroke trace. + +use eframe::egui_wgpu::SurfaceConfig; +use eframe::wgpu::PresentMode; + +/// The present mode and frame latency this launch asked for (`None`: the default). +#[derive(Clone, Copy, Debug, Default, PartialEq)] +pub struct Overrides { + pub mode: Option, + pub latency: Option, +} + +/// Parse the two variables' values. Returns the overrides and a warning per rejected value. +pub fn parse(mode: Option<&str>, latency: Option<&str>) -> (Overrides, Vec) { + let mut warnings = Vec::new(); + let mode = mode.map(str::trim).filter(|v| !v.is_empty()).and_then(|v| { + let m = match v.to_ascii_lowercase().as_str() { + // Only modes every surface takes: wgpu crashes on an explicit Immediate, Mailbox or + // FifoRelaxed the driver doesn't offer (X11/NVIDIA has no Mailbox, #1343). Without + // vsync, wgpu picks Immediate, else Mailbox, else Fifo. + "fifo" | "vsync" => PresentMode::Fifo, + "autovsync" => PresentMode::AutoVsync, + "novsync" | "immediate" | "mailbox" | "autonovsync" => PresentMode::AutoNoVsync, + _ => { + warnings.push(format!("PHOTOCRAFT_PRESENT_MODE={v:?} isn't fifo, autovsync or novsync (immediate, mailbox); ignored")); + return None; + } + }; + Some(m) + }); + let latency = latency.map(str::trim).filter(|v| !v.is_empty()).and_then(|v| match v.parse::() { + Ok(n @ 1..=3) => Some(n), + _ => { + warnings.push(format!("PHOTOCRAFT_FRAME_LATENCY={v:?} isn't 1, 2 or 3; ignored")); + None + } + }); + (Overrides { mode, latency }, warnings) +} + +impl Overrides { + /// `--present-mode` / `--frame-latency` when given, else `PHOTOCRAFT_PRESENT_MODE` / + /// `PHOTOCRAFT_FRAME_LATENCY`; rejected values are logged. + pub fn from_args_or_env(mode: Option<&str>, latency: Option<&str>) -> Self { + let env_mode = std::env::var("PHOTOCRAFT_PRESENT_MODE").ok(); + let env_latency = std::env::var("PHOTOCRAFT_FRAME_LATENCY").ok(); + let (o, warnings) = parse(mode.or(env_mode.as_deref()), latency.or(env_latency.as_deref())); + for w in warnings { + log::warn!("{w}"); + } + o + } + + /// `surface` with the overrides applied. + pub fn apply(&self, surface: SurfaceConfig) -> SurfaceConfig { + SurfaceConfig { + present_mode: self.mode.unwrap_or(surface.present_mode), + desired_maximum_frame_latency: self.latency.or(surface.desired_maximum_frame_latency), + } + } +} + +/// How `surface` presents, for the stroke trace and System Info: "fifo, latency 2", with +/// "(override)" after a value an environment variable set. wgpu's GL backend ignores the latency. +pub fn describe(surface: &SurfaceConfig, overrides: &Overrides) -> String { + let mode = match surface.present_mode { + PresentMode::AutoVsync => "autovsync", + PresentMode::AutoNoVsync => "autonovsync", + PresentMode::Fifo => "fifo", + PresentMode::FifoRelaxed => "fiforelaxed", + PresentMode::Immediate => "immediate", + PresentMode::Mailbox => "mailbox", + }; + let latency = surface.desired_maximum_frame_latency.map_or("default".to_string(), |n| n.to_string()); + let tag = |set: bool| if set { " (override)" } else { "" }; + format!("{mode}{}, latency {latency}{}", tag(overrides.mode.is_some()), tag(overrides.latency.is_some())) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn values_parse_and_bad_ones_are_ignored_with_a_warning() { + assert_eq!(parse(None, None), (Overrides::default(), vec![])); + assert_eq!(parse(Some(""), Some(" ")), (Overrides::default(), vec![])); + let (o, w) = parse(Some(" Mailbox "), Some("1")); + assert_eq!((o.mode, o.latency, w.len()), (Some(PresentMode::AutoNoVsync), Some(1), 0)); + assert_eq!(parse(Some("vsync"), None).0.mode, Some(PresentMode::Fifo)); + // Never a mode a driver may lack (wgpu crashes on it): no vsync falls back by support. + for v in ["IMMEDIATE", "mailbox", "novsync", "autonovsync"] { + assert_eq!(parse(Some(v), None).0.mode, Some(PresentMode::AutoNoVsync), "{v}"); + } + for (mode, latency) in + [(Some("fast"), None), (None, Some("0")), (None, Some("4")), (None, Some("-1")), (None, Some("one")), (None, Some("99999999999"))] + { + let (o, w) = parse(mode, latency); + assert_eq!((o, w.len()), (Overrides::default(), 1), "{mode:?} {latency:?}"); + } + } + + #[test] + fn overrides_replace_only_what_they_set_and_are_described() { + let default = SurfaceConfig::HIGH_THROUGHPUT; + assert_eq!(Overrides::default().apply(default), default); + let latency_only = Overrides { mode: None, latency: Some(1) }; + let s = latency_only.apply(default); + assert_eq!((s.present_mode, s.desired_maximum_frame_latency), (PresentMode::AutoVsync, Some(1))); + assert_eq!(describe(&s, &latency_only), "autovsync, latency 1 (override)"); + let novsync = Overrides { mode: Some(PresentMode::AutoNoVsync), latency: None }; + assert_eq!(describe(&novsync.apply(default), &novsync), "autonovsync (override), latency 2"); + let none = SurfaceConfig { present_mode: PresentMode::Fifo, desired_maximum_frame_latency: None }; + assert_eq!(describe(&none, &Overrides::default()), "fifo, latency default"); + } +} diff --git a/crates/engine/src/prefs.rs b/crates/engine/src/prefs.rs index c732daeefd..838e6fe25e 100644 --- a/crates/engine/src/prefs.rs +++ b/crates/engine/src/prefs.rs @@ -529,6 +529,13 @@ pub struct Performance { /// sliders while they drag) render on a reduced copy: fast, but blocky when zoomed in. Off: /// they render at full resolution. pub low_resolution_previews: bool, + /// Low-latency drawing (applies at next launch): frames reach the screen without waiting for + /// vsync, so a stroke follows the pen about 50 ms sooner (the swap chain's wait, #1343). wgpu + /// picks Immediate where the driver has it (most Vulkan and DX12 drivers on Windows and X11), + /// so the canvas usually tears while it changes; Mailbox (no tearing) only where Immediate is + /// missing. Frames are capped at 240 fps. Off (the default): vsync, smooth but late. Desktop + /// only. + pub low_latency_drawing: bool, /// Linux display server (applies at next launch; see [`LinuxDisplayServer`]). pub linux_display_server: LinuxDisplayServer, /// Memory budget of the layer-effect cache, in MB. @@ -560,6 +567,7 @@ impl Default for Performance { rendering_mode: None, gpu_backend: GpuBackend::Auto, low_resolution_previews: true, + low_latency_drawing: false, linux_display_server: LinuxDisplayServer::Auto, effect_cache_mb: 768, legacy_compositing: false, diff --git a/crates/ui-egui/src/control.rs b/crates/ui-egui/src/control.rs index ff786be197..8cddc60b23 100644 --- a/crates/ui-egui/src/control.rs +++ b/crates/ui-egui/src/control.rs @@ -131,8 +131,9 @@ pub enum Outcome { /// field's value is validated before the first one is applied, so a typo, an unknown field, a /// bad value or a bad nested key can't reply with success while nothing — or only half of it — /// changed (#412). -pub const UI_SET_FIELDS: [&str; 37] = [ +pub const UI_SET_FIELDS: [&str; 38] = [ "tool", + "strokeTrace", "panels", "dock", "dockTabs", @@ -492,6 +493,19 @@ fn dispatch(app: &mut PhotocraftApp, ctx: &egui::Context, req: &ControlRequest) None => None, }; let gradient_classic = bool_field(p, "gradientClassic")?; + // The stroke latency trace (`stroke_trace`), at runtime: on (stderr) or off. A CSV + // file is only for the user's own launch (`--stroke-trace `): a path over the + // control channel would write outside the automation write root. + let stroke_trace = match p.get("strokeTrace") { + None | Some(Value::Null) => None, + Some(Value::Bool(b)) => Some(if *b { "stderr" } else { "off" }), + Some(Value::String(v)) => match v.trim() { + "stderr" | "on" | "true" => Some("stderr"), + "off" | "false" => Some("off"), + _ => return Err("strokeTrace must be true, false, \"stderr\" or \"off\"; a CSV file is set at launch (--stroke-trace )".into()), + }, + Some(_) => return Err("strokeTrace must be true, false, \"stderr\" or \"off\"".into()), + }; // The Eyedropper's options bar (#1649): Sample Size, Sample, Show Sampling Ring. let eyedropper_size = match p.get("eyedropperSampleSize") { Some(v) => Some( @@ -663,6 +677,9 @@ fn dispatch(app: &mut PhotocraftApp, ctx: &egui::Context, req: &ControlRequest) if let Some(mode) = gradient_blend { app.ui.tool_options.gradient_blend_mode = mode; } + if let Some(v) = &stroke_trace { + app.stroke_trace.set(v); + } if let Some(classic) = gradient_classic { app.ui.tool_options.gradient_classic = classic; } @@ -1385,6 +1402,26 @@ mod tests { } } + #[test] + fn ui_set_turns_the_stroke_trace_on_and_off_at_runtime() { + let mut app = PhotocraftApp::new(photocraft_engine::Session::new(), crate::Services::default()); + let ctx = egui::Context::default(); + assert!(!app.stroke_trace.enabled()); + assert_eq!(call(&mut app, &ctx, "ui.set", json!({"strokeTrace": true}))["ok"], true); + assert!(app.stroke_trace.enabled()); + assert_eq!(call(&mut app, &ctx, "ui.set", json!({"strokeTrace": "off"}))["ok"], true); + assert!(!app.stroke_trace.enabled()); + let bad = call(&mut app, &ctx, "ui.set", json!({"strokeTrace": 3, "tool": "Pencil"})); + assert_eq!(bad["ok"], false); + // A file path isn't accepted over the channel (it would write outside the automation + // write root); nothing is created. + let path = std::env::temp_dir().join(format!("photocraft-trace-{}.csv", std::process::id())); + let bad = call(&mut app, &ctx, "ui.set", json!({"strokeTrace": path.to_str().unwrap(), "tool": "Pencil"})); + assert_eq!(bad["ok"], false); + assert!(!path.exists()); + assert!(!app.stroke_trace.enabled() && app.ui.tool != crate::state::Tool::Pencil, "a rejected call applies nothing"); + } + #[test] fn camera_raw_control_updates_and_commits_or_cancels_an_open_raw() { use photocraft_raw::testgen::{DngSpec, mosaic, scene}; diff --git a/crates/ui-egui/src/gpu_canvas.rs b/crates/ui-egui/src/gpu_canvas.rs index 2e583e8e5b..391b0b64a3 100644 --- a/crates/ui-egui/src/gpu_canvas.rs +++ b/crates/ui-egui/src/gpu_canvas.rs @@ -945,6 +945,9 @@ pub struct GpuInfo { pub canvas: String, /// Why the GPU canvas was dropped during this session (device lost or a GPU error). pub lost: Option, + /// Present mode and frame latency ("fifo, latency 2"; launch overrides marked, see the + /// app's `presentation`). Empty in hosts that don't set it. + pub present: String, } impl GpuInfo { diff --git a/crates/ui-egui/src/i18n/cs.tsv b/crates/ui-egui/src/i18n/cs.tsv index 376757e866..1329378dc2 100644 --- a/crates/ui-egui/src/i18n/cs.tsv +++ b/crates/ui-egui/src/i18n/cs.tsv @@ -3804,3 +3804,4 @@ navigator Zoom Zvětšení Take the shortcut from the other command and select it Odebrat zkratku druhému příkazu a vybrat ho Undo Changes Vrátit změny {shortcut} is reserved by the system and can't be assigned. {shortcut} je vyhrazena systémem a nelze ji přiřadit. + Low-Latency Drawing (vsync off, applies at next launch) Kreslení s nízkou latencí (bez vertikální synchronizace, platí po dalším spuštění) diff --git a/crates/ui-egui/src/i18n/de.tsv b/crates/ui-egui/src/i18n/de.tsv index 59a9d470cd..5f56421233 100644 --- a/crates/ui-egui/src/i18n/de.tsv +++ b/crates/ui-egui/src/i18n/de.tsv @@ -3198,3 +3198,4 @@ curves Smooth Glätten Take the shortcut from the other command and select it Den Kurzbefehl dem anderen Befehl entziehen und diesen auswählen Undo Changes Änderungen rückgängig machen {shortcut} is reserved by the system and can't be assigned. {shortcut} ist vom System reserviert und kann nicht zugewiesen werden. + Low-Latency Drawing (vsync off, applies at next launch) Zeichnen mit geringer Latenz (ohne VSync, gilt ab dem nächsten Start) diff --git a/crates/ui-egui/src/i18n/el.tsv b/crates/ui-egui/src/i18n/el.tsv index 0a2e6ddc7c..715e41cb1f 100644 --- a/crates/ui-egui/src/i18n/el.tsv +++ b/crates/ui-egui/src/i18n/el.tsv @@ -3896,3 +3896,4 @@ curves Smooth Εξομάλυνση Take the shortcut from the other command and select it Αφαίρεση της συντόμευσης από την άλλη εντολή και επιλογή της Undo Changes Αναίρεση αλλαγών {shortcut} is reserved by the system and can't be assigned. Η συντόμευση {shortcut} δεσμεύεται από το σύστημα και δεν μπορεί να εκχωρηθεί. + Low-Latency Drawing (vsync off, applies at next launch) Σχεδίαση χαμηλής καθυστέρησης (χωρίς vsync, ισχύει στην επόμενη εκκίνηση) diff --git a/crates/ui-egui/src/i18n/es.tsv b/crates/ui-egui/src/i18n/es.tsv index 2069b068e5..4a0f1c8556 100644 --- a/crates/ui-egui/src/i18n/es.tsv +++ b/crates/ui-egui/src/i18n/es.tsv @@ -4158,3 +4158,4 @@ navigator Zoom Zoom Take the shortcut from the other command and select it Quitar el método abreviado al otro comando y seleccionarlo Undo Changes Deshacer cambios {shortcut} is reserved by the system and can't be assigned. {shortcut} está reservado por el sistema y no se puede asignar. + Low-Latency Drawing (vsync off, applies at next launch) Dibujo de baja latencia (sin sincronización vertical, se aplica al volver a iniciar) diff --git a/crates/ui-egui/src/i18n/fr.tsv b/crates/ui-egui/src/i18n/fr.tsv index d528f1e7ce..2402aec160 100644 --- a/crates/ui-egui/src/i18n/fr.tsv +++ b/crates/ui-egui/src/i18n/fr.tsv @@ -3201,3 +3201,4 @@ curves Smooth Lisser Take the shortcut from the other command and select it Retirer le raccourci de l’autre commande et la sélectionner Undo Changes Annuler les modifications {shortcut} is reserved by the system and can't be assigned. {shortcut} est réservé par le système et ne peut pas être attribué. + Low-Latency Drawing (vsync off, applies at next launch) Dessin à faible latence (sans synchronisation verticale, au prochain lancement) diff --git a/crates/ui-egui/src/i18n/hu.tsv b/crates/ui-egui/src/i18n/hu.tsv index e9f3d33907..343dcadc0c 100644 --- a/crates/ui-egui/src/i18n/hu.tsv +++ b/crates/ui-egui/src/i18n/hu.tsv @@ -4115,3 +4115,4 @@ cameraRaw Yellow Sárga Take the shortcut from the other command and select it A billentyűparancs elvétele a másik parancstól, és annak kijelölése Undo Changes Változások visszavonása {shortcut} is reserved by the system and can't be assigned. A(z) {shortcut} a rendszer számára fenntartott, nem rendelhető hozzá. + Low-Latency Drawing (vsync off, applies at next launch) Kis késleltetésű rajzolás (vsync nélkül, a következő indításkor lép életbe) diff --git a/crates/ui-egui/src/i18n/id.tsv b/crates/ui-egui/src/i18n/id.tsv index 141f956c8e..e4ba61e1a1 100644 --- a/crates/ui-egui/src/i18n/id.tsv +++ b/crates/ui-egui/src/i18n/id.tsv @@ -3188,3 +3188,4 @@ curves Smooth Haluskan Take the shortcut from the other command and select it Ambil pintasan dari perintah lain dan pilih perintah itu Undo Changes Batalkan Perubahan {shortcut} is reserved by the system and can't be assigned. {shortcut} dicadangkan oleh sistem dan tidak dapat ditetapkan. + Low-Latency Drawing (vsync off, applies at next launch) Menggambar latensi rendah (tanpa vsync, berlaku saat peluncuran berikutnya) diff --git a/crates/ui-egui/src/i18n/it.tsv b/crates/ui-egui/src/i18n/it.tsv index 714932312e..6b933287c6 100644 --- a/crates/ui-egui/src/i18n/it.tsv +++ b/crates/ui-egui/src/i18n/it.tsv @@ -4317,3 +4317,4 @@ shape Appearance Aspetto Take the shortcut from the other command and select it Togli la scorciatoia all'altro comando e selezionalo Undo Changes Annulla modifiche {shortcut} is reserved by the system and can't be assigned. {shortcut} è riservata dal sistema e non può essere assegnata. + Low-Latency Drawing (vsync off, applies at next launch) Disegno a bassa latenza (senza vsync, si applica al prossimo avvio) diff --git a/crates/ui-egui/src/i18n/ja.tsv b/crates/ui-egui/src/i18n/ja.tsv index a78fe8e7ca..2f6b7d7f5e 100644 --- a/crates/ui-egui/src/i18n/ja.tsv +++ b/crates/ui-egui/src/i18n/ja.tsv @@ -3872,3 +3872,4 @@ curves Smooth スムーズ Take the shortcut from the other command and select it もう一方のコマンドからショートカットを外して選択 Undo Changes 変更を取り消し {shortcut} is reserved by the system and can't be assigned. {shortcut} はシステムで予約されているため割り当てられません。 + Low-Latency Drawing (vsync off, applies at next launch) 低遅延描画(垂直同期オフ、次回起動時に適用) diff --git a/crates/ui-egui/src/i18n/ko.tsv b/crates/ui-egui/src/i18n/ko.tsv index 2e12c33799..3c056c627b 100644 --- a/crates/ui-egui/src/i18n/ko.tsv +++ b/crates/ui-egui/src/i18n/ko.tsv @@ -3374,3 +3374,4 @@ curves Smooth 매끄럽게 Take the shortcut from the other command and select it 다른 명령에서 단축키를 빼고 그 명령 선택 Undo Changes 변경 사항 취소 {shortcut} is reserved by the system and can't be assigned. {shortcut}은(는) 시스템에서 예약되어 할당할 수 없습니다. + Low-Latency Drawing (vsync off, applies at next launch) 저지연 그리기(수직 동기화 끔, 다음 실행 시 적용) diff --git a/crates/ui-egui/src/i18n/nl.tsv b/crates/ui-egui/src/i18n/nl.tsv index 32df1e20a2..c80f86bf81 100644 --- a/crates/ui-egui/src/i18n/nl.tsv +++ b/crates/ui-egui/src/i18n/nl.tsv @@ -3956,3 +3956,4 @@ curves Smooth Vloeiend maken Take the shortcut from the other command and select it De sneltoets van de andere opdracht afnemen en die selecteren Undo Changes Wijzigingen ongedaan maken {shortcut} is reserved by the system and can't be assigned. {shortcut} is door het systeem gereserveerd en kan niet worden toegewezen. + Low-Latency Drawing (vsync off, applies at next launch) Tekenen met lage latentie (zonder vsync, na de volgende start) diff --git a/crates/ui-egui/src/i18n/pl.tsv b/crates/ui-egui/src/i18n/pl.tsv index 8b571ff7d6..85df6b8157 100644 --- a/crates/ui-egui/src/i18n/pl.tsv +++ b/crates/ui-egui/src/i18n/pl.tsv @@ -3794,3 +3794,4 @@ curves Smooth Wygładź Take the shortcut from the other command and select it Odbierz skrót drugiemu poleceniu i zaznacz je Undo Changes Cofnij zmiany {shortcut} is reserved by the system and can't be assigned. {shortcut} jest zarezerwowany przez system i nie można go przypisać. + Low-Latency Drawing (vsync off, applies at next launch) Rysowanie z małym opóźnieniem (bez synchronizacji pionowej, od następnego uruchomienia) diff --git a/crates/ui-egui/src/i18n/pt-br.tsv b/crates/ui-egui/src/i18n/pt-br.tsv index 8e6dcc4993..211442ba20 100644 --- a/crates/ui-egui/src/i18n/pt-br.tsv +++ b/crates/ui-egui/src/i18n/pt-br.tsv @@ -3986,3 +3986,4 @@ layerLabel No Color Sem cor Take the shortcut from the other command and select it Tirar o atalho do outro comando e selecioná-lo Undo Changes Desfazer alterações {shortcut} is reserved by the system and can't be assigned. {shortcut} é reservado pelo sistema e não pode ser atribuído. + Low-Latency Drawing (vsync off, applies at next launch) Desenho de baixa latência (sem sincronização vertical, aplicado na próxima inicialização) diff --git a/crates/ui-egui/src/i18n/ru.tsv b/crates/ui-egui/src/i18n/ru.tsv index 6c80c470cf..9dfcc85093 100644 --- a/crates/ui-egui/src/i18n/ru.tsv +++ b/crates/ui-egui/src/i18n/ru.tsv @@ -3166,3 +3166,4 @@ curves Smooth Сгладить Take the shortcut from the other command and select it Снять комбинацию с другой команды и выбрать её Undo Changes Отменить изменения {shortcut} is reserved by the system and can't be assigned. Комбинация {shortcut} зарезервирована системой и не может быть назначена. + Low-Latency Drawing (vsync off, applies at next launch) Рисование с малой задержкой (без вертикальной синхронизации, после перезапуска) diff --git a/crates/ui-egui/src/i18n/sr.tsv b/crates/ui-egui/src/i18n/sr.tsv index 9b30313e22..bbc0ef528d 100644 --- a/crates/ui-egui/src/i18n/sr.tsv +++ b/crates/ui-egui/src/i18n/sr.tsv @@ -4269,3 +4269,4 @@ shape Appearance Izgled Take the shortcut from the other command and select it Uzmi prečicu drugoj komandi i izaberi je Undo Changes Poništi izmene {shortcut} is reserved by the system and can't be assigned. {shortcut} je rezervisana od strane sistema i ne može se dodeliti. + Low-Latency Drawing (vsync off, applies at next launch) Crtanje sa malim kašnjenjem (bez vsync-a, primenjuje se pri sledećem pokretanju) diff --git a/crates/ui-egui/src/i18n/tr.tsv b/crates/ui-egui/src/i18n/tr.tsv index 8e20ad53dc..b63bdd7349 100644 --- a/crates/ui-egui/src/i18n/tr.tsv +++ b/crates/ui-egui/src/i18n/tr.tsv @@ -3926,3 +3926,4 @@ cameraData Lens Lens Take the shortcut from the other command and select it Kısayolu diğer komuttan al ve o komutu seç Undo Changes Değişiklikleri Geri Al {shortcut} is reserved by the system and can't be assigned. {shortcut} sistem tarafından ayrılmıştır ve atanamaz. + Low-Latency Drawing (vsync off, applies at next launch) Düşük gecikmeli çizim (dikey eşitleme kapalı, sonraki açılışta geçerli) diff --git a/crates/ui-egui/src/i18n/uk.tsv b/crates/ui-egui/src/i18n/uk.tsv index 9fcf5e85bc..923ec694af 100644 --- a/crates/ui-egui/src/i18n/uk.tsv +++ b/crates/ui-egui/src/i18n/uk.tsv @@ -3895,3 +3895,4 @@ curves Smooth Згладити Take the shortcut from the other command and select it Забрати скорочення в іншої команди й вибрати її Undo Changes Скасувати зміни {shortcut} is reserved by the system and can't be assigned. {shortcut} зарезервовано системою, його не можна призначити. + Low-Latency Drawing (vsync off, applies at next launch) Малювання з малою затримкою (без вертикальної синхронізації, після перезапуску) diff --git a/crates/ui-egui/src/i18n/vi.tsv b/crates/ui-egui/src/i18n/vi.tsv index 515966c515..be40230ca3 100644 --- a/crates/ui-egui/src/i18n/vi.tsv +++ b/crates/ui-egui/src/i18n/vi.tsv @@ -3682,3 +3682,4 @@ curves Smooth Làm mượt Take the shortcut from the other command and select it Lấy phím tắt khỏi lệnh kia và chọn lệnh đó Undo Changes Hoàn tác thay đổi {shortcut} is reserved by the system and can't be assigned. {shortcut} được hệ thống dành riêng và không thể gán. + Low-Latency Drawing (vsync off, applies at next launch) Vẽ độ trễ thấp (tắt vsync, áp dụng ở lần khởi động sau) diff --git a/crates/ui-egui/src/i18n/zh-hans.tsv b/crates/ui-egui/src/i18n/zh-hans.tsv index cbf3c9d08f..7c563780db 100644 --- a/crates/ui-egui/src/i18n/zh-hans.tsv +++ b/crates/ui-egui/src/i18n/zh-hans.tsv @@ -2812,3 +2812,4 @@ curves Smooth 平滑 Take the shortcut from the other command and select it 从另一个命令移除该快捷键并选中它 Undo Changes 撤消更改 {shortcut} is reserved by the system and can't be assigned. {shortcut} 由系统保留,无法指定。 + Low-Latency Drawing (vsync off, applies at next launch) 低延迟绘画(关闭垂直同步,下次启动时生效) diff --git a/crates/ui-egui/src/i18n/zh-hant.tsv b/crates/ui-egui/src/i18n/zh-hant.tsv index 30aa3f5a94..9376cb05bb 100644 --- a/crates/ui-egui/src/i18n/zh-hant.tsv +++ b/crates/ui-egui/src/i18n/zh-hant.tsv @@ -2797,3 +2797,4 @@ curves Smooth 平滑 Take the shortcut from the other command and select it 從另一個指令移除該快速鍵並選取它 Undo Changes 還原變更 {shortcut} is reserved by the system and can't be assigned. {shortcut} 由系統保留,無法指定。 + Low-Latency Drawing (vsync off, applies at next launch) 低延遲繪圖(關閉垂直同步,下次啟動時生效) diff --git a/crates/ui-egui/src/lib.rs b/crates/ui-egui/src/lib.rs index 3a981c41f3..34b346af00 100644 --- a/crates/ui-egui/src/lib.rs +++ b/crates/ui-egui/src/lib.rs @@ -800,6 +800,12 @@ pub struct PhotocraftApp { /// `jobs_ui`). The desktop app turns it on; off (the default), everything runs inline as /// before, which tests and scripts rely on. pub background_jobs: bool, + /// Shortest time between frames (ms), or `None` for no cap. A present mode that doesn't wait + /// for the display (Low-Latency Drawing) would otherwise redraw as fast as it can while + /// anything keeps requesting repaints (a drag hovering, autoscroll, Liquify held still). + /// After #4472 (astigmata). + pub min_frame_ms: Option, + last_frame_start_ms: f64, /// Background job bookkeeping: opening tabs, control replies waiting on a job. pub jobs: jobs_ui::JobsUi, #[cfg(all(debug_assertions, not(target_arch = "wasm32")))] @@ -935,6 +941,8 @@ impl PhotocraftApp { allow_close: false, stylus: Default::default(), background_jobs: false, + min_frame_ms: None, + last_frame_start_ms: 0.0, jobs: Default::default(), #[cfg(all(debug_assertions, not(target_arch = "wasm32")))] live_tokens: theme::live::LiveTokens::from_env(), @@ -1759,6 +1767,20 @@ impl PhotocraftApp { } } +impl PhotocraftApp { + /// Wait out the rest of [`Self::min_frame_ms`] since the last frame started. + fn cap_frame_rate(&mut self) { + #[cfg(not(target_arch = "wasm32"))] + if let Some(min) = self.min_frame_ms.filter(|m| m.is_finite() && *m > 0.0) { + let wait = min.min(100.0) - (gpu_canvas::now_ms() - self.last_frame_start_ms); + if wait > 0.0 { + std::thread::sleep(std::time::Duration::from_secs_f64(wait / 1000.0)); + } + } + self.last_frame_start_ms = gpu_canvas::now_ms(); + } +} + impl eframe::App for PhotocraftApp { fn on_exit(&mut self) { if let Err(err) = tool_memory::save_on_exit(self) { @@ -1767,6 +1789,7 @@ impl eframe::App for PhotocraftApp { } fn logic(&mut self, ctx: &egui::Context, frame: &mut eframe::Frame) { + self.cap_frame_rate(); i18n::set_current(i18n::Lang::from_pref(&self.session.prefs().interface.language)); // Caps Lock state read from the OS once per frame (see `Services::caps_lock`): the canvas // cursor block needs it before the frame renders. `None` (Wayland/web) keeps `false`. @@ -2004,7 +2027,10 @@ impl eframe::App for PhotocraftApp { if self.stroke_trace.enabled() { self.stroke_trace.canvas(self.perf.frame_refresh_ms); let info = &self.perf.gpu_info; - self.stroke_trace.end_frame(ui_ms, self.live_stroke.is_some(), || format!("{} {}", info.backend, info.adapter).trim().to_string()); + self.stroke_trace.end_frame(ui_ms, self.live_stroke.is_some(), || { + let gpu = format!("{} {}", info.backend, info.adapter).trim().to_string(); + if info.present.is_empty() { gpu } else { format!("{gpu}, {}", info.present) } + }); } // Synthetic input is injected one press/release step per frame: keep frames coming until // the queue is empty, then release control replies waiting on it. diff --git a/crates/ui-egui/src/prefs_ui.rs b/crates/ui-egui/src/prefs_ui.rs index 99750ae7f1..795ea84cea 100644 --- a/crates/ui-egui/src/prefs_ui.rs +++ b/crates/ui-egui/src/prefs_ui.rs @@ -1120,6 +1120,11 @@ fn humanize(key: &str) -> String { if key == "nudgeBundlePauseMs" { return "Nudge Bundle Pause (ms)".into(); } + // Performance: presentation without vsync (#1343); like the other graphics settings, it + // takes effect when the window next opens. + if key == "lowLatencyDrawing" { + return "Low-Latency Drawing (vsync off, applies at next launch)".into(); + } let mut s = String::new(); for (i, ch) in key.chars().enumerate() { if i == 0 { @@ -1556,6 +1561,8 @@ fn field_visible(section: &str, obj: &Map, k: &str, query: &str) // View › Rulers (⌘R) sets it, as in Photoshop. || path == "unitsAndRulers.showRulers" || (section == "performance" && matches!(k, "useGpu" | "gpuBackend" | "renderingMode")) + // The browser presents frames itself: Low-Latency Drawing does nothing there. + || (cfg!(target_arch = "wasm32") && path == "performance.lowLatencyDrawing") || (section == "export" && !export_field_visible(obj, k)) || (section == "tools" && !tools_field_visible(obj, k)); (query.is_empty() || preference_matches(section, k, query)) diff --git a/crates/ui-egui/src/stroke_trace.rs b/crates/ui-egui/src/stroke_trace.rs index 206cc7e81a..809414ed49 100644 --- a/crates/ui-egui/src/stroke_trace.rs +++ b/crates/ui-egui/src/stroke_trace.rs @@ -113,13 +113,32 @@ impl StrokeTrace { /// Reads `PHOTOCRAFT_STROKE_TRACE` (off on the web, where there is no clock). pub fn from_env() -> Self { #[cfg(not(target_arch = "wasm32"))] - if let Some(v) = std::env::var_os("PHOTOCRAFT_STROKE_TRACE").filter(|v| !v.is_empty()) { - let output = if v == "1" || v == "stderr" { Output::Stderr } else { Output::Csv(v.into()) }; - return Self { output, ..Default::default() }; + if let Some(v) = std::env::var_os("PHOTOCRAFT_STROKE_TRACE") { + return Self::from_value(&v.to_string_lossy()); } Self::default() } + /// A trace set by value: `""`, `"0"` or `"off"` is off, `"1"` or `"stderr"` prints + /// summaries, anything else is a CSV path (`--stroke-trace`, `PHOTOCRAFT_STROKE_TRACE`; the + /// control channel's `ui.set {"strokeTrace"}` passes only on or off, never a path). + pub fn from_value(v: &str) -> Self { + let v = v.trim(); + let output = match v { + _ if cfg!(target_arch = "wasm32") => Output::Off, + "" | "0" | "off" | "false" => Output::Off, + "1" | "stderr" | "true" => Output::Stderr, + path => Output::Csv(path.into()), + }; + Self { output, ..Default::default() } + } + + /// Switch the output, keeping the last summary. + pub fn set(&mut self, v: &str) { + let last = self.last.take(); + *self = Self { last, ..Self::from_value(v) }; + } + pub fn enabled(&self) -> bool { self.output != Output::Off } @@ -265,6 +284,19 @@ mod tests { assert!(off.last.is_none() && off.rows.is_empty()); } + #[test] + fn a_trace_is_set_by_value() { + assert_eq!(StrokeTrace::from_value("off").output, Output::Off); + assert_eq!(StrokeTrace::from_value(" 0 ").output, Output::Off); + assert_eq!(StrokeTrace::from_value("stderr").output, Output::Stderr); + assert_eq!(StrokeTrace::from_value("true").output, Output::Stderr); + assert_eq!(StrokeTrace::from_value("t.csv").output, Output::Csv("t.csv".into())); + let mut t = on(); + t.last = Some(Summary::default()); + t.set("off"); + assert!(!t.enabled() && t.last.is_some(), "turning it off keeps the last summary"); + } + #[test] fn csv_rows_and_odd_numbers() { let rows = [Row { t: 1.0, interval: 16.5, moves: 2, stroke: 0.25, canvas: 1.0, ui: 3.0, cpu_prev: None }]; diff --git a/docs/control-protocol.md b/docs/control-protocol.md index 8536507104..2bbcab2469 100644 --- a/docs/control-protocol.md +++ b/docs/control-protocol.md @@ -53,6 +53,7 @@ The transport is `apps/photocraft/src/control_server.rs`, and the handlers are i - `ui.set` also accepts `gradientBlendMode` (a layer blend mode name, such as `Difference`) and `gradientClassic` (boolean) for the Gradient tool options bar. - `ui.set` also accepts `camTransformOnDrop` (boolean), the Content-Aware Move tool's Transform On Drop checkbox: a drop then puts a scale/rotate box on the patch (`ui.inspect` › `camTransform`: its document/revision, drag `offset` and `frame`), driven with `ui.pointer` (handles as Free Transform's; outside the box rotates), committed with `ui.key Enter` (one `paint.contentAwareMove` with `scaleX`, `scaleY`, `rotate`) or dropped with `Escape`. - `ui.set` also accepts `tileGrid` (boolean), a developer overlay of the active layer's storage tiles: the tile grid over the document, every stored tile filled and labelled `tx,ty`, tiles with identical pixels in a shared colour and unique ones in blue. It has no menu item and View › Extras doesn't hide it. `ui.inspect` reports it as `view.tile_grid`. +- `ui.set` also accepts `strokeTrace` (`true`, `false`, `"stderr"` or `"off"`): turn the stroke latency trace on or off at runtime; each stroke's summary is in `ui.inspect` `perf.strokeTrace`. A CSV file of every frame is set only at launch (`PHOTOCRAFT_STROKE_TRACE=` / `--stroke-trace `): the control channel doesn't write files outside the automation write root - `ui.set` also accepts `layerFilter` (#5048), the Layers panel filter row: `{by?, kinds?, name?, mode?}`, any subset. `by` is `kind`, `name` or `mode` (the filter-type dropdown); `kinds` lists the Kind toggles that are on (`pixel`, `adjustment`, `type`, `shape`, `smart`); `name` matches part of a layer name, ignoring case; `mode` is a blend mode name (`Multiply`, `Pass Through`). Kind and Name filter only once a kind is on or text is typed; Mode always filters, by Normal at first, as in Photoshop. Fields left out keep their value; a bad one is an error and changes nothing. - Each painting tool keeps its own brush, as in Photoshop: `tools.setBrush` and `ui.set {brushSize}` change the active tool's brush, and switching tools swaps in that tool's own (size, hardness, mode, opacity, flow, dynamics, smoothing). A tool used for the first time starts from the brush in hand. The colours, the erase flag and the stroke seed stay shared. So `ui.set {tool: "eraser", brushSize: 12}` sets the Eraser's size, and `ui.set {tool: "brush"}` afterwards brings the Brush's own size back. - `ui.set` also accepts the Eyedropper options bar (#1649): `eyedropperSampleSize` (`"point"` or 1, 3, 5, 11, 31, 51, 101: the side of the averaged square, shared by the Eyedropper, a painting tool's Alt-click eyedropper, the Curves/Color Picker eyedroppers and the Info panel), `eyedropperSample` (`current`, `currentAndBelow`, `all`, `allNoAdjustments`, `currentAndBelowNoAdjustments`) and `eyedropperRing` (boolean, Show Sampling Ring). `ui.inspect` reports them under `toolOptions` (`eyedropper_size`, `eyedropper_sample`, `eyedropper_ring`). The engine command `document.sampleColor {x, y, size, sampleLayer}` returns the colour such a sample picks. diff --git a/docs/development.md b/docs/development.md index 3e7f5fb66e..76b4a2e31c 100644 --- a/docs/development.md +++ b/docs/development.md @@ -119,7 +119,9 @@ By default PhotoCraft's own crates log at `info` and everything else at `warn`. | `PHOTOCRAFT_CPU_COMPOSE=1` | Keep the wgpu canvas but composite on the CPU (compare GPU vs CPU renders, e.g. with the snapshot example) | | `PHOTOCRAFT_LOCALE` | Override Auto's native UI language for this launch; unsupported tags use English. See [system language detection](localization.md#first-launch-and-system-language). | | `PHOTOCRAFT_FX_TRACE=1` | Print the CPU time spent on GPU effect shapes and distance fields per rebuild | -| `PHOTOCRAFT_STROKE_TRACE=1` (or `=`) | Time every frame of each brush stroke; print a summary per stroke, and with a path also append each frame to that CSV (see [Stroke latency trace](#stroke-latency-trace)) | +| `PHOTOCRAFT_STROKE_TRACE=1` (or `=`; also `--stroke-trace `, or `ui.set {"strokeTrace": true|false}` at runtime, without a file) | Time every frame of each brush stroke; print a summary per stroke, and with a path also append each frame to that CSV (see [Stroke latency trace](#stroke-latency-trace)) | +| `PHOTOCRAFT_PRESENT_MODE=novsync` (or `--present-mode`) | Present mode for this launch: `fifo`, `autovsync`, or `novsync` (also `immediate`, `mailbox`: no vsync, the first of Immediate, Mailbox, Fifo the driver offers). Default: vsync, or no vsync when Preferences › Performance › Low-Latency Drawing is on (it ships off). For latency comparisons; shown in the stroke trace | +| `PHOTOCRAFT_FRAME_LATENCY=1` (or `--frame-latency`) | Frames the swap chain may queue, 1 to 3 (wgpu's GL backend ignores it). For latency comparisons; shown in the stroke trace | | `PHOTOCRAFT_THEME_FILE=tokens.json` | **Debug builds only:** live design-token overrides, re-read on change | ### Live design tokens @@ -148,7 +150,7 @@ stroke trace: 62 frames on vulkan NVIDIA GeForce RTX 5090 (p50/p95 ms): interval | `cpu` | eframe's CPU time per frame: UI, tessellation and GPU submission, without the vsync wait | | `wait` | `interval − cpu`: waiting on vsync or the swap chain, or idle | -What the app can't time is the rest of input-to-photon: the OS input queue before a frame starts (up to one `interval`), frames queued for the display (the surface keeps up to two; the GL backend ignores that setting), and the compositor. So the first line names the backend. The last summary is also in the control channel's `perf.strokeTrace` (`ui.inspect`). The code is `crates/ui-egui/src/stroke_trace.rs`. +What the app can't time is the rest of input-to-photon: the OS input queue before a frame starts (up to one `interval`), frames queued for the display (the surface keeps up to two; the GL backend ignores that setting), and the compositor. So the first line names the backend and how frames are presented. To compare presentation settings without rebuilding, launch with `PHOTOCRAFT_PRESENT_MODE` and `PHOTOCRAFT_FRAME_LATENCY` (see [Environment variables](#environment-variables)): with `novsync` the app no longer waits on vsync (`wait` near 0): each refresh shows the newest frame, and where the driver only offers Immediate, the screen may tear. The last summary is also in the control channel's `perf.strokeTrace` (`ui.inspect`). The code is `crates/ui-egui/src/stroke_trace.rs`. ## Driving the app programmatically diff --git a/docs/scorecard.md b/docs/scorecard.md index 1e0576ba02..8dbbea612e 100644 --- a/docs/scorecard.md +++ b/docs/scorecard.md @@ -9,7 +9,7 @@ Generated by `cargo xtask scorecard` from `perf/budgets.toml`, `perf/baseline.js | [Performance](#performance) | 3 | 22 | 22 | 56 scenarios: 3 within budget, 22 over budget or failing, 9 tracked only, 17 not run, 5 not measurable yet (baseline 2026-10-05) | | [File compatibility](#file-compatibility) | 7 | 14 | 6 | io corpus: oracle ≥ 148/170, round trip ≥ 169/170; psd-tools corpus: oracle ≥ 249/309, round trip ≥ 308/309; photoshop oracles: oracle ≥ 188/256, round trip ≥ 258/256 | | [Tools](#tools) | 33 | 11 | 0 | | -| [Workspace and UI](#workspace-and-ui) | 13 | 6 | 4 | settings that do nothing: 26 of 166 (target 0) | +| [Workspace and UI](#workspace-and-ui) | 13 | 6 | 4 | settings that do nothing: 26 of 167 (target 0) | | [Type](#type) | 4 | 3 | 2 | | | [Automation](#automation) | 5 | 2 | 4 | 631 of 631 menu items live (100.0%) | | [Reliability](#reliability) | 5 | 2 | 0 | never-crash lints on 29 of 29 crates; panic_hunt present | @@ -177,7 +177,7 @@ Corpus floors (`crates/io/tests/corpus.rs`): the enforced minimums; the corpus t ## Workspace and UI -**Settings that do nothing: 26 of 166** (target 0, #204). Counted by the prefs audit (`xtask/src/scorecard.rs`): a `Preferences` field counts as read when code outside the prefs model (`prefs.rs` except its `impl Session` blocks; tests excluded; the schema-driven dialog never names a field) accesses `section.field`, or `.section` and `.field` in the same file, or calls a section method that reads `self.field`. It is a heuristic lower bound: a generic name can look read when it isn't. +**Settings that do nothing: 26 of 167** (target 0, #204). Counted by the prefs audit (`xtask/src/scorecard.rs`): a `Preferences` field counts as read when code outside the prefs model (`prefs.rs` except its `impl Session` blocks; tests excluded; the schema-driven dialog never names a field) accesses `section.field`, or `.section` and `.field` in the same file, or calls a section method that reads `self.field`. It is a heuristic lower bound: a generic name can look read when it isn't. Unread: `general.color_picker`, `general.animated_zoom`, `general.zoom_resizes_windows`, `workspace.auto_show_hidden_panels`, `workspace.enable_floating_document_window_docking`, `file_handling.save_in_background`, `performance.cache_levels`, `performance.legacy_compositing`, `scratch_disks.disks`, `units_and_rulers.column_width`, `units_and_rulers.gutter`, `plug_ins.show_extension_panels`, `plug_ins.allow_scripts_to_connect`, `plug_ins.generator_enabled`, `type_.text_engine`, `type_.recent_fonts`, `enhanced_controls.scrubby_slider_acceleration`, `enhanced_controls.touch_gestures`, `raw_defaults.color_space`, `raw_defaults.bit_depth`, `raw_defaults.resolution`, `raw_defaults.sharpen_for`, `raw_defaults.open_as_smart_object`, `raw_defaults.apply_auto_tone`, `integrations.allow_agent_control`, `integrations.control_port`. From 8376363d8ce1c8d7f70760636227425753845dc2 Mon Sep 17 00:00:00 2001 From: Tanish Manku Date: Thu, 15 Oct 2026 15:26:09 -0700 Subject: [PATCH 35/47] Shortcuts without the UI: shell commands and tools in the engine, reserved keys, set files (#5801) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * Shortcuts without the UI: shell commands and tools in the engine, reserved keys, set files A retro against "everything is drivable headless; the UI is a layer" found the shortcut work leaning on the UI. Fixed: - The shell's command table (UI_COMMANDS: View › Zoom In, the panels…) moves into the engine as data (shell_commands::SHELL_COMMANDS; menus::UI_COMMANDS is now an alias), with the tools (shell_commands::TOOLS: id, Photoshop name, default key; a test keeps it equal to the toolbar). edit.keyboardShortcuts binds, lists, imports, exports and checks them for conflicts with no UI: a headless .kys import takes tool keys, and an export is the whole set. Tools may share a key (Shift cycling); a command given a tool's key takes it from them. - Reserved system keys are refused by the engine (the dialog uses the same check) and left out of imported sets. - importKys / exportKys / summarize take {"path": …} to read or write files, like actions.save; the automation gate refuses those paths for MCP. * Keyboard shortcuts: bindings commit only after their exports are written (a bad target changes nothing); docs: the command knows the tools * Keyboard shortcuts: both export targets are resolved and checked before either file is written; a failed later write removes the earlier one * docs: reserved keys per platform * Keyboard shortcuts export: files are staged next to their targets and moved into place only when all are written; a failure leaves the user's files untouched * Keyboard shortcut exports all or nothing: unique create_new staging and backups beside each target, restored on any failure; the same target twice refused; docs: Ctrl+Shift+Esc * Shortcut exports: a failed backup move removes its placeholder; undo restores backups without deleting first and names any it couldn't; read-only targets refused up front --- crates/automation/src/workspace.rs | 10 + crates/engine/src/kys.rs | 27 +- crates/engine/src/lib.rs | 1 + crates/engine/src/prefs.rs | 384 ++++++++++++++++++++-- crates/engine/src/prefs/tests.rs | 51 +++ crates/engine/src/shell_commands.rs | 194 +++++++++++ crates/engine/src/shortcut_sets.rs | 40 ++- crates/engine/tests/shortcuts_headless.rs | 146 ++++++++ crates/ui-egui/src/kys_import.rs | 3 +- crates/ui-egui/src/menus.rs | 88 +---- crates/ui-egui/src/prefs_ui.rs | 28 +- crates/ui-egui/src/tool_shortcuts.rs | 14 + docs/control-protocol.md | 2 +- 13 files changed, 815 insertions(+), 173 deletions(-) create mode 100644 crates/engine/src/shell_commands.rs create mode 100644 crates/engine/tests/shortcuts_headless.rs diff --git a/crates/automation/src/workspace.rs b/crates/automation/src/workspace.rs index 23b64ae30e..f73a702385 100644 --- a/crates/automation/src/workspace.rs +++ b/crates/automation/src/workspace.rs @@ -369,6 +369,10 @@ fn command_uses_ambient_path(id: &str, params: &Value) -> bool { } // With a `path` the file is written there; without one the bytes come back as `data`. "actions.save" | "swatches.export" => params.get("path").is_some(), + // Shortcut sets: a .kys read or written by path, or a Summarize page written by path. + "edit.keyboardShortcuts" => { + params.get("path").is_some() || ["importKys", "exportKys", "summarize"].iter().any(|k| params.get(*k).and_then(|v| v.get("path")).is_some()) + } "plugin.reload" => true, _ => false, } @@ -712,6 +716,12 @@ mod tests { assert!(authorize_desktop_engine_command("image.mode.cmyk", &serde_json::json!({})).is_err()); assert!(authorize_engine_command("image.mode.rgb", &serde_json::json!({"profile": "/outside/profile.icc"})).is_err()); assert!(authorize_engine_command("image.mode.rgb", &serde_json::json!({"profile": "srgb"})).is_ok()); + // Shortcut sets: text in and out is fine; a path reads or writes the filesystem. + let ks = |p: serde_json::Value| authorize_engine_command("edit.keyboardShortcuts", &p); + assert!(ks(serde_json::json!({"exportKys": true, "summarize": true, "importKys": ""})).is_ok()); + for key in ["importKys", "exportKys", "summarize"] { + assert!(ks(serde_json::json!({ key: {"path": "/outside/set.kys"} })).is_err(), "{key}"); + } assert!(authorize_engine_command("filter.distort.displace", &serde_json::json!({"mapPath": "outside.png"})).is_err()); assert!(authorize_engine_command("layer.setAdjustment", &serde_json::json!({"file": "outside.cube"})).is_err()); assert!(authorize_engine_command("prefs.set", &serde_json::json!({"path": "colorSettings.workingRgb", "value": "outside.icc"})).is_err()); diff --git a/crates/engine/src/kys.rs b/crates/engine/src/kys.rs index f996e23475..6a0ff99c84 100644 --- a/crates/engine/src/kys.rs +++ b/crates/engine/src/kys.rs @@ -160,12 +160,14 @@ fn key(label: &str) -> String { l.trim_end_matches('…').trim().to_lowercase() } -/// The engine's own candidates: every command in a menu, in command order. +/// The engine's own candidates: every command in a menu (the engine's, then the shell's), in +/// command order. pub fn command_candidates() -> Vec { - crate::command_specs() - .iter() - .filter(|c| !c.menu.is_empty()) - .map(|c| Candidate { id: c.id.to_string(), label: c.label.to_string(), default: c.shortcut.map(str::to_string) }) + let engine = crate::command_specs().iter().map(|c| (c.id, c.label, c.menu, c.shortcut)); + engine + .chain(crate::shell_commands::SHELL_COMMANDS.iter().copied()) + .filter(|c| !c.2.is_empty()) + .map(|(id, label, _, default)| Candidate { id: id.to_string(), label: label.to_string(), default: default.map(str::to_string) }) .collect() } @@ -219,8 +221,8 @@ pub fn plan(candidates: impl IntoIterator, set: &KysSet) -> Pl } /// The tool keys of `set` matched to `tools` (our tool id and Photoshop's name for it, which is -/// ours too: "Move Tool"): tool id → key (a single letter, upper case), and the names with no -/// tool here. A row PhotoCraft wrote names the tool by id. A key that isn't one letter is skipped. +/// ours too: "Move Tool"): tool id → key (normalised: a letter, or an F-key), and the names with no +/// tool here. A row PhotoCraft wrote names the tool by id. A key with modifiers is skipped. pub fn plan_tools<'a>(tools: impl IntoIterator, set: &KysSet) -> (BTreeMap, Vec) { let tools: Vec<(&str, &str)> = tools.into_iter().collect(); let mut keys = BTreeMap::new(); @@ -232,11 +234,10 @@ pub fn plan_tools<'a>(tools: impl IntoIterator, set: unknown.push(row.name.clone()); continue; }; - let mut letters = row.shortcuts.first().map(|k| k.trim()).unwrap_or_default().chars(); - if let (Some(c), None) = (letters.next(), letters.next()) - && c.is_ascii_alphanumeric() - { - keys.insert((*id).to_string(), c.to_ascii_uppercase().to_string()); + // One key without modifiers (Photoshop's letters; our tools may also take an F-key). + let key = row.shortcuts.first().and_then(|k| normalize_shortcut(k.trim())); + if let Some(k) = key.filter(|k| !k.contains('+') || k == "+") { + keys.insert((*id).to_string(), k); } } (keys, unknown) @@ -448,7 +449,7 @@ mod tests { assert_eq!(keys.get("tools.select.move").map(String::as_str), Some("V")); assert_eq!(keys.get("tools.select.pencil").map(String::as_str), Some("N"), "case-insensitive, upper-cased"); assert_eq!(keys.get("tools.select.brush").map(String::as_str), Some("B"), "by pc-id"); - assert!(!keys.contains_key("tools.select.lasso"), "a tool key is one letter"); + assert!(!keys.contains_key("tools.select.lasso"), "a tool key has no modifiers"); assert_eq!(unknown, vec!["Imaginary Tool".to_string()]); // Our own export reads back by id. let out = write("T", &[Entry { id: "tools.select.pencil".into(), label: "Pencil Tool".into(), tool: true, keys: vec!["F".into()] }]); diff --git a/crates/engine/src/lib.rs b/crates/engine/src/lib.rs index ba618f6ac8..41915c544d 100644 --- a/crates/engine/src/lib.rs +++ b/crates/engine/src/lib.rs @@ -103,6 +103,7 @@ pub mod sample_cmds; pub mod sampler_cmds; pub mod select_extra_cmds; pub mod selection_cmds; +pub mod shell_commands; pub mod shortcut_sets; pub mod slice_cmds; pub mod smart_cmds; diff --git a/crates/engine/src/prefs.rs b/crates/engine/src/prefs.rs index 838e6fe25e..beccc97b49 100644 --- a/crates/engine/src/prefs.rs +++ b/crates/engine/src/prefs.rs @@ -1503,9 +1503,65 @@ pub fn default_alternates(id: &str) -> impl Iterator + '_ { ALTERNATE_SHORTCUTS.iter().filter(move |(c, _)| *c == id).map(|(_, sc)| *sc) } -/// Every bindable id with its default: commands, then the temporary tools. +/// Keys the system takes before the app sees them (switching apps, closing the window, force +/// quit): `edit.keyboardShortcuts` won't bind them, as Photoshop won't. `sc` is in our notation, +/// where `Cmd` is Ctrl off the Mac. +pub fn reserved_shortcut(sc: &str) -> bool { + let Some(n) = normalize_shortcut(sc) else { return false }; + let reserved: &[&str] = if cfg!(target_os = "macos") { + &["Cmd+Tab", "Cmd+Shift+Tab", "Cmd+Alt+Escape", "Cmd+`"] + } else if cfg!(target_os = "windows") { + &["Alt+Tab", "Alt+Shift+Tab", "Alt+F4", "Cmd+Alt+Delete", "Cmd+Escape", "Cmd+Shift+Escape"] + } else { + // Linux and the BSDs: window switching and closing, and the virtual consoles. + &[ + "Alt+Tab", + "Alt+Shift+Tab", + "Alt+F4", + "Cmd+Alt+Delete", + "Cmd+Alt+F1", + "Cmd+Alt+F2", + "Cmd+Alt+F3", + "Cmd+Alt+F4", + "Cmd+Alt+F5", + "Cmd+Alt+F6", + "Cmd+Alt+F7", + "Cmd+Alt+F8", + "Cmd+Alt+F9", + "Cmd+Alt+F10", + "Cmd+Alt+F11", + "Cmd+Alt+F12", + ] + }; + reserved.iter().filter_map(|r| normalize_shortcut(r)).any(|r| r == n) +} + +/// What a bindable id is: a command (the engine's or the shell's), a tool, or a held tool. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum BindKind { + Command, + Tool, + Hold, +} + +/// Every bindable id: (id, label, menu path, default key, kind). The engine's commands, the +/// shell's ([`crate::shell_commands::SHELL_COMMANDS`]), the tools and the held temporary tools, +/// so Edit › Keyboard Shortcuts works the same with or without the app's UI. +pub fn bindable_items() -> impl Iterator, BindKind)> { + let engine = crate::command_specs().iter().map(|c| (c.id, c.label, c.menu, c.shortcut, BindKind::Command)); + let shell = crate::shell_commands::SHELL_COMMANDS.iter().map(|c| (c.0, c.1, c.2, c.3, BindKind::Command)); + let tools = crate::shell_commands::TOOLS.iter().map(|t| (t.0, t.1, &["Tools"][..], t.2, BindKind::Tool)); + let hold = TEMPORARY_TOOLS.iter().map(|t| (t.0, t.1, &["Tools", "Temporary"][..], Some(t.2), BindKind::Hold)); + engine.chain(shell).chain(tools).chain(hold) +} + +/// Every bindable id with its default. fn bindable() -> impl Iterator)> { - crate::command_specs().iter().map(|c| (c.id, c.shortcut)).chain(TEMPORARY_TOOLS.iter().map(|t| (t.0, Some(t.2)))) + bindable_items().map(|i| (i.0, i.3)) +} + +fn is_tool(id: &str) -> bool { + crate::shell_commands::tool_default(id).is_some() } /// Shortcuts bound to more than one command: (shortcut, ids). `bindings` are (id, shortcut). @@ -1874,18 +1930,45 @@ fn keyboard_shortcuts(s: &mut Session, p: &Value) -> Result { let imported; let mut import = None; let p = match p.get("importKys") { - Some(xml) => { - let xml = xml.as_str().ok_or_else(|| bad(cmd, "`importKys` is the text of a .kys file"))?; + Some(v) => { + // The file's text, or {"path": …} to read it. + let read; + let xml = match v { + Value::String(s) => s.as_str(), + Value::Object(o) => { + let path = + o.get("path").and_then(Value::as_str).filter(|s| !s.trim().is_empty()).ok_or_else(|| bad(cmd, "`importKys.path` is a .kys file"))?; + read = read_text_file(path, crate::kys::MAX_BYTES).map_err(|e| bad(cmd, format!("{path}: {e}")))?; + read.as_str() + } + _ => return Err(bad(cmd, "`importKys` is the text of a .kys file, or {\"path\": …}")), + }; let set = crate::kys::parse(xml).map_err(|e| bad(cmd, e))?; let plan = crate::kys::plan(crate::kys::command_candidates(), &set); let mut merged = serde_json::Map::new(); - for (id, sc) in &plan.set { + // A key the system reserves is left out (reported as `reserved`), not a failed import. + let mut skipped: Vec = Vec::new(); + for (id, sc) in plan.set.iter().filter(|(id, sc)| { + let bad = reserved_shortcut(sc); + if bad { + skipped.push(format!("{id}: {sc}")); + } + !bad + }) { let default = crate::command_specs().iter().find(|c| c.id == id).and_then(|c| c.shortcut).and_then(normalize_shortcut); merged.insert(id.clone(), if default.as_deref() == Some(sc.as_str()) { Value::Null } else { json!(sc) }); } + // Tool keys: a tool's own letter restores it (and its Shift cycling). + let tools = crate::shell_commands::TOOLS.iter().map(|t| (t.0, t.1)); + let (tool_keys, unknown_tools) = crate::kys::plan_tools(tools, &set); + for (id, key) in &tool_keys { + let own = crate::shell_commands::tool_default(id).flatten().is_some_and(|d| d.eq_ignore_ascii_case(key)); + merged.insert(id.clone(), if own { Value::Null } else { json!(key) }); + } merged.extend(p.get("set").and_then(Value::as_object).cloned().unwrap_or_default()); let mut alternates = serde_json::Map::new(); for (id, keys) in &plan.alternate_keys { + let keys: &Vec = &keys.iter().filter(|k| !reserved_shortcut(k)).cloned().collect(); let main = plan.set.get(id).map(String::as_str); let default = Preferences::default().alternates(id, main); alternates.insert(id.clone(), if *keys == default { Value::Null } else { json!(keys) }); @@ -1900,6 +1983,9 @@ fn keyboard_shortcuts(s: &mut Session, p: &Value) -> Result { "alternates": plan.alternates, "alternateKeys": plan.alternate_keys, "toolKeys": set.tool_keys, + "tools": tool_keys, + "unknownTools": unknown_tools, + "reserved": skipped, "windows": set.windows, })); let mut with_import = p.clone(); @@ -1922,6 +2008,17 @@ fn keyboard_shortcuts(s: &mut Session, p: &Value) -> Result { if !set.is_empty() || !alts.is_empty() { let allow_unknown = p.get("allowUnknown").and_then(Value::as_bool).unwrap_or(false); changed = true; + // Reserved system keys can't be bound, by hand or from a file. + let reserved = |v: &Value| -> Option { + let keys = match v { + Value::String(s) => vec![s.clone()], + other => alternate_keys(other).unwrap_or_default(), + }; + keys.into_iter().find(|k| reserved_shortcut(k)) + }; + if let Some((id, k)) = set.iter().chain(alts.iter()).find_map(|(id, v)| reserved(v).map(|k| (id, k))) { + return Err(bad(cmd, format!("`{k}` (for `{id}`) is reserved by the system and can't be assigned"))); + } for (id, v) in set { if !bindable().any(|(b, _)| b == id) && !allow_unknown { return Err(bad(cmd, format!("unknown command `{id}`"))); @@ -1943,9 +2040,11 @@ fn keyboard_shortcuts(s: &mut Session, p: &Value) -> Result { .chain(alts.iter().flat_map(|(id, v)| alternate_keys(v).unwrap_or_default().into_iter().map(move |k| (id.as_str(), k)))) .collect(); for (id, sc) in assigned { - // Shell commands (View › Zoom In) have alternates but no engine default. - let shell = ALTERNATE_SHORTCUTS.iter().filter(|(c, _)| crate::commands::find(c).is_none()).map(|(c, _)| (*c, None)); - for (c, def) in bindable().chain(shell) { + for (c, def) in bindable() { + // Tools may share a key: they cycle with Shift, as Photoshop's groups do. + if is_tool(id) && is_tool(c) { + continue; + } // Never strip a command that this same call is assigning: // two entries for one key are a clash inside the call, which // the returned conflicts list reports (#719). Stripping @@ -1967,16 +2066,11 @@ fn keyboard_shortcuts(s: &mut Session, p: &Value) -> Result { } // Then the sets are saved, deleted or switched, from the bindings as they now stand. changed |= crate::shortcut_sets::store(&mut next, p).map_err(|e| bad(cmd, e))?; - if changed { - s.edit_prefs(|p| *p = next); - } - let prefs = s.prefs(); + // The reply and the exports read the bindings as they will be; they are committed only once + // the exports are written, so a bad export target changes nothing. + let prefs = &next; let filter = p.get("filter").and_then(Value::as_str).map(str::to_ascii_lowercase); - let temporary = TEMPORARY_TOOLS.iter().map(|t| (t.0, t.1, &["Tools", "Temporary"][..], Some(t.2), true)); - let list: Vec = crate::command_specs() - .iter() - .map(|c| (c.id, c.label, c.menu, c.shortcut, false)) - .chain(temporary) + let list: Vec = bindable_items() .filter(|c| filter.as_ref().is_none_or(|f| c.0.to_ascii_lowercase().contains(f) || c.1.to_ascii_lowercase().contains(f))) .filter(|c| { p.get("list").and_then(Value::as_bool).unwrap_or(false) @@ -1984,15 +2078,17 @@ fn keyboard_shortcuts(s: &mut Session, p: &Value) -> Result { || prefs.shortcuts.contains_key(c.0) || prefs.alternate_shortcuts.contains_key(c.0) }) - .map(|(id, label, menu, def, hold)| { + .map(|(id, label, menu, def, kind)| { let main = prefs.shortcut(id, def); let defaults: Vec<&str> = default_alternates(id).collect(); let mut v = json!({"id": id, "label": label, "menu": menu, "default": def, "shortcut": main, "alternates": prefs.alternates(id, main)}); if !defaults.is_empty() { v["defaultAlternates"] = json!(defaults); } - if hold { - v["hold"] = json!(true); + match kind { + BindKind::Hold => v["hold"] = json!(true), + BindKind::Tool => v["tool"] = json!(true), + BindKind::Command => {} } v }) @@ -2003,30 +2099,59 @@ fn keyboard_shortcuts(s: &mut Session, p: &Value) -> Result { bindings.extend(main.map(|m| (id, m.to_string()))); bindings.extend(prefs.alternates(id, main).into_iter().map(|k| (id, k))); } - let conflicts: Vec = - conflicts(bindings.iter().map(|(id, k)| (*id, k.as_str()))).into_iter().map(|(sc, ids)| json!({"shortcut": sc, "commands": ids})).collect(); + // Tools sharing a key are a cycling group, not a conflict. + let conflicts: Vec = conflicts(bindings.iter().map(|(id, k)| (*id, k.as_str()))) + .into_iter() + .filter(|(_, ids)| !ids.iter().all(|id| is_tool(id))) + .map(|(sc, ids)| json!({"shortcut": sc, "commands": ids})) + .collect(); let mut out = json!({"overrides": prefs.shortcuts, "alternateOverrides": prefs.alternate_shortcuts, "commands": list, "conflicts": conflicts}); if let (Some(o), Value::Object(sets)) = (out.as_object_mut(), crate::shortcut_sets::describe(prefs)) { o.extend(sets); } - // The bindings as a `.kys` file, or Photoshop's Summarize page (the engine's commands; the - // app's dialog writes both with the shell's commands and tools added). + // The bindings as a `.kys` file, or Photoshop's Summarize page: every bindable item (the + // engine's commands, the shell's commands and the tools). let set_name = if prefs.shortcut_set.is_empty() { crate::shortcut_sets::DEFAULTS_NAME } else { prefs.shortcut_set.as_str() }; - if p.get("exportKys").and_then(Value::as_bool) == Some(true) { + // Both targets are resolved and their texts made before anything is written, and a path that + // can't be written is refused before either file is: a bad export changes nothing. + let kys_target = file_target(cmd, p.get("exportKys"))?; + let html_target = file_target(cmd, p.get("summarize"))?; + let mut files: Vec<(&str, String, String)> = Vec::new(); + if let Some(target) = kys_target { let entries: Vec = - crate::shortcut_sets::bound(prefs).into_iter().map(|(id, label, _, keys)| crate::kys::Entry { id, label, tool: false, keys }).collect(); - out["kys"] = json!(crate::kys::write(set_name, &entries)); + crate::shortcut_sets::bound(prefs).into_iter().map(|b| crate::kys::Entry { id: b.id, label: b.label, tool: b.tool, keys: b.keys }).collect(); + let text = crate::kys::write(set_name, &entries); + match target { + Some(path) => files.push(("kysPath", path, text)), + None => out["kys"] = json!(text), + } } - if let Some(v) = p.get("summarize").filter(|v| !v.is_null() && *v != &json!(false)) { + if let Some(target) = html_target { + let v = p.get("summarize").cloned().unwrap_or(Value::Null); let mac = v.get("mac").and_then(Value::as_bool).unwrap_or(cfg!(target_os = "macos")); let mut sections: Vec = Vec::new(); - for (_, label, menu, keys) in crate::shortcut_sets::bound(prefs) { + for crate::shortcut_sets::Bound { label, menu, keys, .. } in crate::shortcut_sets::bound(prefs) { match sections.iter_mut().find(|(m, _)| *m == menu) { Some((_, rows)) => rows.push((label, keys)), None => sections.push((menu, vec![(label, keys)])), } } - out["html"] = json!(crate::shortcut_sets::summarize(set_name, §ions, mac)); + let html = crate::shortcut_sets::summarize(set_name, §ions, mac); + match target { + Some(path) => files.push(("htmlPath", path, html)), + None => out["html"] = json!(html), + } + } + for (_, path, _) in &files { + writable(path).map_err(|e| bad(cmd, format!("{path}: {e}")))?; + } + let list: Vec<(&str, &str)> = files.iter().map(|(_, path, text)| (path.as_str(), text.as_str())).collect(); + write_files_atomically(&list, &rename_path).map_err(|e| bad(cmd, e))?; + for (key, path, _) in &files { + out[*key] = json!(path); + } + if changed { + s.edit_prefs(|p| *p = next); } if let Some(import) = import { out["import"] = import; @@ -2034,6 +2159,203 @@ fn keyboard_shortcuts(s: &mut Session, p: &Value) -> Result { Ok(out) } +/// An output parameter (`exportKys`, `summarize`): absent or false → `None`; true (or an object +/// without `path`) → `Some(None)`, the text comes back in the reply; `{"path": …}` → +/// `Some(Some(path))`, written there. +fn file_target(cmd: &str, v: Option<&Value>) -> Result>> { + match v { + None | Some(Value::Null) | Some(Value::Bool(false)) => Ok(None), + Some(Value::Bool(true)) => Ok(Some(None)), + Some(Value::Object(o)) => match o.get("path") { + None | Some(Value::Null) => Ok(Some(None)), + Some(Value::String(s)) if !s.trim().is_empty() => Ok(Some(Some(s.clone()))), + Some(_) => Err(bad(cmd, "`path` must be a non-empty string")), + }, + Some(_) => Err(bad(cmd, "expected true or {\"path\": …}")), + } +} + +#[cfg(not(target_arch = "wasm32"))] +fn read_text_file(path: &str, max: usize) -> std::result::Result { + let meta = std::fs::metadata(path).map_err(|e| e.to_string())?; + if !meta.is_file() || meta.len() > max as u64 { + return Err(format!("not a file of at most {} KiB", max / 1024)); + } + std::fs::read_to_string(path).map_err(|e| e.to_string()) +} + +#[cfg(target_arch = "wasm32")] +fn read_text_file(_: &str, _: usize) -> std::result::Result { + Err("paths can't be read on the web; pass the file's text instead".into()) +} + +/// Whether `path` can be written: not a folder, in a folder that exists. +#[cfg(not(target_arch = "wasm32"))] +fn writable(path: &str) -> std::result::Result<(), String> { + let p = std::path::Path::new(path); + if p.is_dir() { + return Err("is a folder".into()); + } + if std::fs::metadata(p).is_ok_and(|m| m.permissions().readonly()) { + return Err("is read-only".into()); + } + match p.parent().filter(|d| !d.as_os_str().is_empty()) { + Some(d) if !d.is_dir() => Err("its folder doesn't exist".into()), + _ => Ok(()), + } +} + +#[cfg(target_arch = "wasm32")] +fn writable(_: &str) -> std::result::Result<(), String> { + Err("paths can't be written on the web; omit `path` to get the text back".into()) +} + +#[cfg(not(target_arch = "wasm32"))] +fn rename_path(from: &std::path::Path, to: &std::path::Path) -> std::io::Result<()> { + std::fs::rename(from, to) +} + +#[cfg(target_arch = "wasm32")] +fn rename_path(_: &std::path::Path, _: &std::path::Path) -> std::io::Result<()> { + Err(std::io::Error::other("paths can't be written on the web")) +} + +/// Where `path` points, for telling whether two targets are one file: its folder resolved, its +/// name compared without case where the file system ignores it. +#[cfg(not(target_arch = "wasm32"))] +fn path_key(path: &str) -> String { + let p = std::path::Path::new(path); + let dir = p.parent().filter(|d| !d.as_os_str().is_empty()).unwrap_or(std::path::Path::new(".")); + let dir = dir.canonicalize().unwrap_or_else(|_| dir.to_path_buf()); + let key = dir.join(p.file_name().unwrap_or_default()).to_string_lossy().into_owned(); + if cfg!(any(target_os = "windows", target_os = "macos")) { key.to_lowercase() } else { key } +} + +/// A new, empty file beside `path` with a name nothing else uses (`create_new`), for staging +/// (`kind` "tmp") or a backup ("bak"). +#[cfg(not(target_arch = "wasm32"))] +fn sibling(path: &str, kind: &str) -> std::io::Result<(std::path::PathBuf, std::fs::File)> { + for n in 0..1000u32 { + let p = std::path::PathBuf::from(format!("{path}.{}-{n}.photocraft-{kind}", std::process::id())); + match std::fs::OpenOptions::new().write(true).create_new(true).open(&p) { + Ok(f) => return Ok((p, f)), + Err(e) if e.kind() == std::io::ErrorKind::AlreadyExists => continue, + Err(e) => return Err(e), + } + } + Err(std::io::Error::other("no free name beside it")) +} + +/// Write `files` ((path, text)) all or nothing. Each text is staged in a new file beside its +/// target; once every one is written, each existing target is moved aside to a backup and the +/// staged file moved in. Any failure moves the backups back and removes what was staged, so the +/// user's files end as they began; on success the backups go. Two targets naming one file are +/// refused. `rename` is `std::fs::rename` (a test fails it on purpose). +#[cfg(not(target_arch = "wasm32"))] +pub(crate) fn write_files_atomically( + files: &[(&str, &str)], + rename: &dyn Fn(&std::path::Path, &std::path::Path) -> std::io::Result<()>, +) -> std::result::Result<(), String> { + use std::io::Write; + use std::path::{Path, PathBuf}; + for (i, (a, _)) in files.iter().enumerate() { + if files.iter().skip(i + 1).any(|(b, _)| path_key(a) == path_key(b)) { + return Err(format!("{a}: two exports to the same file")); + } + } + let mut staged: Vec = Vec::new(); + let drop_all = |paths: &[PathBuf]| { + for p in paths { + let _ = std::fs::remove_file(p); + } + }; + for (path, text) in files { + let (tmp, mut f) = match sibling(path, "tmp") { + Ok(v) => v, + Err(e) => { + drop_all(&staged); + return Err(format!("{path}: {e}")); + } + }; + staged.push(tmp); + if let Err(e) = f.write_all(text.as_bytes()).and_then(|()| f.sync_all()) { + drop_all(&staged); + return Err(format!("{path}: {e}")); + } + } + // (target, its backup) of each file moved in so far. + let mut moved: Vec<(&Path, Option)> = Vec::new(); + // Put each backup back over its target (a target that had none is removed). What couldn't be + // put back is named, so the user can find their previous file. + let undo = |moved: &[(&Path, Option)]| -> String { + let mut stranded = Vec::new(); + for (target, backup) in moved.iter().rev() { + match backup { + Some(b) => { + if rename(b, target).is_err() { + stranded.push(format!("the previous {} is at {}", target.display(), b.display())); + } + } + None => { + let _ = std::fs::remove_file(target); + } + } + } + if stranded.is_empty() { String::new() } else { format!(" ({})", stranded.join("; ")) } + }; + for ((path, _), tmp) in files.iter().zip(&staged) { + let target = Path::new(*path); + let backup = if target.exists() { + let moved_aside = sibling(path, "bak").and_then(|(b, f)| { + drop(f); + match rename(target, &b) { + Ok(()) => Ok(b), + Err(e) => { + // The placeholder the backup name reserved goes too. + let _ = std::fs::remove_file(&b); + Err(e) + } + } + }); + match moved_aside { + Ok(b) => Some(b), + Err(e) => { + let stranded = undo(&moved); + drop_all(&staged); + return Err(format!("{path}: {e}{stranded}")); + } + } + } else { + None + }; + if let Err(e) = rename(tmp, target) { + let mut stranded = undo(&moved); + if let Some(b) = &backup + && rename(b, target).is_err() + { + stranded = format!("{stranded} (the previous {path} is at {})", b.display()); + } + drop_all(&staged); + return Err(format!("{path}: {e}{stranded}")); + } + moved.push((target, backup)); + } + for (_, backup) in moved { + if let Some(b) = backup { + let _ = std::fs::remove_file(b); + } + } + Ok(()) +} + +#[cfg(target_arch = "wasm32")] +pub(crate) fn write_files_atomically( + files: &[(&str, &str)], + _: &dyn Fn(&std::path::Path, &std::path::Path) -> std::io::Result<()>, +) -> std::result::Result<(), String> { + if files.is_empty() { Ok(()) } else { Err("paths can't be written on the web; omit `path` to get the text back".into()) } +} + /// Edit › Menus: hide/show items and give them colours. fn menus(s: &mut Session, p: &Value) -> Result { let ids = |k: &str| -> Vec { @@ -2159,7 +2481,7 @@ pub fn specs() -> Vec { "Keyboard Shortcuts…", ["Edit"], Some("Cmd+Alt+Shift+K"), - r##"{"set":{"|tools.temporary.hand|zoomIn|zoomOut":"Cmd+Shift+X"|""(remove)|null(default)}?,"alternates":{"":["F3",…]|[](none)|null(default)}?,"reset":true|["",…]?,"removeConflicts":bool=true,"filter":str?,"list":bool=false,"importKys":"<.kys XML>"?,"useSet":""|""(defaults)?,"saveSet":""?,"deleteSet":""?,"sets":{"":{"shortcuts":{…},"alternateShortcuts":{…}}}?,"activeSet":""|""?,"exportKys":bool?,"summarize":bool|{"mac":bool}?}"##, + r##"{"set":{"|tools.temporary.hand|zoomIn|zoomOut":"Cmd+Shift+X"|""(remove)|null(default)}?,"alternates":{"":["F3",…]|[](none)|null(default)}?,"reset":true|["",…]?,"removeConflicts":bool=true,"filter":str?,"list":bool=false,"importKys":"<.kys XML>"|{"path":".kys"}?,"useSet":""|""(defaults)?,"saveSet":""?,"deleteSet":""?,"sets":{"":{"shortcuts":{…},"alternateShortcuts":{…}}}?,"activeSet":""|""?,"exportKys":true|{"path":".kys"}?,"summarize":true|{"mac":bool,"path":".html"}?} (with a path the file is written and the reply has kysPath/htmlPath instead of kys/html)"##, keyboard_shortcuts, true ), diff --git a/crates/engine/src/prefs/tests.rs b/crates/engine/src/prefs/tests.rs index bf2fe52efd..374131e85c 100644 --- a/crates/engine/src/prefs/tests.rs +++ b/crates/engine/src/prefs/tests.rs @@ -827,3 +827,54 @@ fn shift_key_tool_switch_is_on_by_default_and_a_stored_false_is_kept() { let stored: Tools = serde_json::from_value(json!({"useShiftKeyForToolSwitch": false})).unwrap(); assert!(!stored.use_shift_key_for_tool_switch); } + +/// Shortcut exports are all or nothing (#5801): a rename that fails after the first file was +/// moved in puts the user's files back as they were and leaves nothing staged. +#[test] +fn exports_are_written_all_or_nothing() { + use super::write_files_atomically; + let dir = std::env::temp_dir().join(format!("photocraft-atomic-{}", std::process::id())); + std::fs::create_dir_all(&dir).unwrap(); + let (a, b) = (dir.join("a.kys"), dir.join("b.html")); + std::fs::write(&a, "old A").unwrap(); + std::fs::write(&b, "old B").unwrap(); + let (pa, pb) = (a.to_str().unwrap(), b.to_str().unwrap()); + let staged_moves = std::cell::Cell::new(0); + let failing = |from: &std::path::Path, to: &std::path::Path| { + if from.to_string_lossy().ends_with("photocraft-tmp") { + staged_moves.set(staged_moves.get() + 1); + if staged_moves.get() == 2 { + return Err(std::io::Error::other("disk gone")); + } + } + std::fs::rename(from, to) + }; + assert!(write_files_atomically(&[(pa, "new A"), (pb, "new B")], &failing).is_err()); + assert_eq!((std::fs::read_to_string(&a).unwrap(), std::fs::read_to_string(&b).unwrap()), ("old A".into(), "old B".into())); + let leftovers = || std::fs::read_dir(&dir).unwrap().filter_map(|e| e.ok()).filter(|e| e.file_name().to_string_lossy().contains(".photocraft-")).count(); + assert_eq!(leftovers(), 0); + // A backup move that fails leaves no empty placeholder behind. + let backups = std::cell::Cell::new(0); + let failing_backup = |from: &std::path::Path, to: &std::path::Path| { + if to.to_string_lossy().ends_with("photocraft-bak") { + backups.set(backups.get() + 1); + if backups.get() == 2 { + return Err(std::io::Error::other("locked")); + } + } + std::fs::rename(from, to) + }; + assert!(write_files_atomically(&[(pa, "new A"), (pb, "new B")], &failing_backup).is_err()); + assert_eq!((std::fs::read_to_string(&a).unwrap(), std::fs::read_to_string(&b).unwrap()), ("old A".into(), "old B".into())); + assert_eq!(leftovers(), 0); + // The same target twice is refused before anything is written. + assert!(write_files_atomically(&[(pa, "x"), (pa, "y")], &|f, t| std::fs::rename(f, t)).is_err()); + assert_eq!(std::fs::read_to_string(&a).unwrap(), "old A"); + // A target named like a staging file is just a file. + let odd = dir.join("c.kys.photocraft-tmp"); + std::fs::write(&odd, "old C").unwrap(); + write_files_atomically(&[(pa, "new A"), (odd.to_str().unwrap(), "new C")], &|f, t| std::fs::rename(f, t)).unwrap(); + assert_eq!((std::fs::read_to_string(&a).unwrap(), std::fs::read_to_string(&odd).unwrap()), ("new A".into(), "new C".into())); + assert_eq!(leftovers(), 1, "only c.kys.photocraft-tmp itself"); + let _ = std::fs::remove_dir_all(&dir); +} diff --git a/crates/engine/src/shell_commands.rs b/crates/engine/src/shell_commands.rs new file mode 100644 index 0000000000..2bd765673f --- /dev/null +++ b/crates/engine/src/shell_commands.rs @@ -0,0 +1,194 @@ +//! The commands the app's shell carries out itself (view, panels, windows: they change no +//! document), and the tools, as data: what they're called and which keys they have by default. +//! The shell implements them (`photocraft_ui_egui::menus::UI_COMMANDS`, its `Tool`); the engine +//! knows them so a headless session sees every bindable command: Edit › Keyboard Shortcuts' +//! list, conflicts, `.kys` import and export all work without the UI (the UI is a layer on +//! top). The app's tests keep these tables equal to its own. + +/// Shell commands: id, label, menu path, default shortcut. +pub const SHELL_COMMANDS: &[(&str, &str, &[&str], Option<&str>)] = &[ + ("ui.symmetryTransform", "Transform Symmetry", &[], None), + // Right-click the options bar's tool icon (#2825); `tool.resetAll` asks first. + ("tool.reset", "Reset Tool", &[], None), + ("tool.resetAll", "Reset All Tools", &[], None), + ("window.panel.group", "Change Panel Group", &[], None), + ("window.panel.layout", "Change Panel Layout", &[], None), + ("window.panel.move", "Move Panel", &[], None), + ("window.panel.float", "Float Panel", &[], None), + ("window.panel.dock", "Dock Panel", &[], None), + ("window.panel.activate", "Activate Panel", &[], None), + ("window.panel.close", "Close Panel", &[], None), + ("file.open", "Open…", &["File"], Some("Cmd+O")), + ("file.save", "Save", &["File"], Some("Cmd+S")), + ("file.saveAs", "Save As…", &["File"], Some("Cmd+Shift+S")), + ("file.exit", "Exit", &["File"], Some("Cmd+Q")), + ("edit.freeTransform", "Free Transform", &["Edit"], Some("Cmd+T")), + ("file.export.exportAs", "Export As…", &["File", "Export"], Some("Cmd+Alt+Shift+W")), + ("file.export.quickExportAsPng", "Quick Export as PNG", &["File", "Export"], None), + ("edit.transform.scale", "Scale", &["Edit", "Transform"], None), + ("edit.transform.rotate", "Rotate", &["Edit", "Transform"], None), + ("edit.transform.skew", "Skew", &["Edit", "Transform"], None), + ("edit.transform.distort", "Distort", &["Edit", "Transform"], None), + ("edit.transform.perspective", "Perspective", &["Edit", "Transform"], None), + ("select.selectAndMask", "Select and Mask…", &["Select"], Some("Cmd+Alt+R")), + ("view.rulers", "Rulers", &["View"], Some("Cmd+R")), + ("view.show.grid", "Grid", &["View", "Show"], Some("Cmd+'")), + ("view.show.guides", "Guides", &["View", "Show"], Some("Cmd+;")), + ("view.snap", "Snap", &["View"], Some("Cmd+Shift+;")), + ("view.lockGuides", "Lock Guides", &["View"], Some("Cmd+Alt+;")), + ("view.extras", "Extras", &["View"], Some("Cmd+H")), + ("view.show.targetPath", "Target Path", &["View", "Show"], Some("Cmd+Shift+H")), + ("view.screenMode.cycle", "Cycle Screen Mode", &[], Some("F")), + // Photoshop's interface brightness shortcuts: step through the four Pro themes. + ("view.darkenInterface", "Darken Interface", &[], Some("Cmd+Shift+F1")), + ("view.lightenInterface", "Lighten Interface", &[], Some("Cmd+Shift+F2")), + ("edit.freeTransformCopy", "Free Transform a Copy", &[], Some("Cmd+Alt+T")), + ("type.editText", "Edit Type", &[], None), + ("view.zoomIn", "Zoom In", &["View"], Some("Cmd+=")), + ("view.zoomOut", "Zoom Out", &["View"], Some("Cmd+-")), + ("view.fitOnScreen", "Fit on Screen", &["View"], Some("Cmd+0")), + ("view.actualPixels", "100%", &["View"], Some("Cmd+1")), + ("view.rotateView", "Rotate View", &[], None), + ("view.resetView", "Reset View", &["View"], None), + ("window.newWindowForDocument", "New Window for Document", &["Window", "Arrange"], None), + ("window.toggle.layers", "Layers", &["Window"], Some("F7")), + ("window.toggle.history", "History", &["Window"], None), + ("window.toggle.properties", "Properties", &["Window"], None), + ("window.toggle.color", "Color", &["Window"], Some("F6")), + ("window.toggle.brushSettings", "Brush Settings", &["Window"], Some("F5")), + ("window.toggle.brushes", "Brushes", &["Window"], None), + ("window.toggle.navigator", "Navigator", &["Window"], None), + ("window.toggle.toolbar", "Tools", &["Window"], None), + // Photoshop's Tab and ⇧Tab: all panels (Tools, options bar and the dock), or only the dock. + ("window.togglePanels", "Show/Hide All Panels", &[], Some("Tab")), + ("window.toggle.dock", "Show/Hide Panels", &[], Some("Shift+Tab")), + ("window.toggle.options", "Options", &["Window"], None), + // Photoshop's Ctrl+Tab and Ctrl+Shift+Tab, on macOS too (⌘Tab belongs to the system, #2340). + ("window.nextDocument", "Next Document", &[], Some("Ctrl+Tab")), + ("window.previousDocument", "Previous Document", &[], Some("Ctrl+Shift+Tab")), + ("window.theme.toggle", "Next Appearance Mode", &["Window"], None), + ("window.theme.pro", "Pro Theme", &["Window", "Theme"], None), + ("window.theme.proMedium", "Pro Medium Gray Theme", &["Window", "Theme"], None), + ("window.theme.proLightGray", "Pro Light Gray Theme", &["Window", "Theme"], None), + ("window.theme.proLight", "Pro Light Theme", &["Window", "Theme"], None), + ("window.theme.studio", "Studio Theme", &["Window", "Theme"], None), + ("window.theme.studioLight", "Studio Light Theme", &["Window", "Theme"], None), + ("window.theme.classic", "Classic Theme", &["Window", "Theme"], None), + ("window.theme.solarizedDark", "Solarized Dark Theme", &["Window", "Theme"], None), + ("window.theme.adwaita", "Adwaita Light Theme", &["Window", "Theme"], None), + ("window.theme.adwaitaDark", "Adwaita Dark Theme", &["Window", "Theme"], None), + ("window.theme.breezeLight", "Breeze Light Theme", &["Window", "Theme"], None), + ("window.theme.breezeDark", "Breeze Dark Theme", &["Window", "Theme"], None), + ("window.theme.softLight", "Soft Light Theme", &["Window", "Theme"], None), + ("window.theme.softDark", "Soft Dark Theme", &["Window", "Theme"], None), + ("window.theme.system", "Sync with system", &["Window", "Theme"], None), + ("edit.search", "Search…", &["Edit"], Some("Cmd+F")), + ("help.discord", "Join the ArtCraft Discord…", &["Help"], None), + ("help.website", "PhotoCraft Website", &["Help"], None), + ("help.artcraftWebsite", "ArtCraft Website", &["Help"], None), + ("help.github", "PhotoCraft on GitHub", &["Help"], None), + ("help.reportIssue", "Report an Issue…", &["Help"], None), + ("help.systemInfo", "System Info…", &["Help"], None), + ("help.about", "About PhotoCraft", &["Help"], None), +]; + +/// The tools, in toolbar order: shortcut id (`tools.select.`), Photoshop's name for it +/// (ours too), and its default key. Tools that share a key cycle with Shift (Photoshop's +/// tool groups), so a shared default letter isn't a conflict. +pub const TOOLS: &[(&str, &str, Option<&str>)] = &[ + ("tools.select.move", "Move Tool", Some("V")), + ("tools.select.artboard", "Artboard Tool", Some("V")), + ("tools.select.rectMarquee", "Rectangular Marquee Tool", Some("M")), + ("tools.select.ellipseMarquee", "Elliptical Marquee Tool", Some("M")), + ("tools.select.singleRowMarquee", "Single Row Marquee Tool", None), + ("tools.select.singleColumnMarquee", "Single Column Marquee Tool", None), + ("tools.select.lasso", "Lasso Tool", Some("L")), + ("tools.select.polygonLasso", "Polygonal Lasso Tool", Some("L")), + ("tools.select.magneticLasso", "Magnetic Lasso Tool", Some("L")), + ("tools.select.magicWand", "Magic Wand Tool", Some("W")), + ("tools.select.crop", "Crop Tool", Some("C")), + ("tools.select.perspectiveCrop", "Perspective Crop Tool", Some("C")), + ("tools.select.eyedropper", "Eyedropper Tool", Some("I")), + ("tools.select.colorSampler", "Color Sampler Tool", Some("I")), + ("tools.select.ruler", "Ruler Tool", Some("I")), + ("tools.select.note", "Note Tool", Some("I")), + ("tools.select.count", "Count Tool", Some("I")), + ("tools.select.brush", "Brush Tool", Some("B")), + ("tools.select.pencil", "Pencil Tool", Some("B")), + ("tools.select.mixerBrush", "Mixer Brush Tool", Some("B")), + ("tools.select.colorReplacement", "Color Replacement Tool", Some("B")), + ("tools.select.eraser", "Eraser Tool", Some("E")), + ("tools.select.backgroundEraser", "Background Eraser Tool", Some("E")), + ("tools.select.magicEraser", "Magic Eraser Tool", Some("E")), + ("tools.select.gradient", "Gradient Tool", Some("G")), + ("tools.select.paintBucket", "Paint Bucket Tool", Some("G")), + ("tools.select.type", "Horizontal Type Tool", Some("T")), + ("tools.select.verticalType", "Vertical Type Tool", Some("T")), + ("tools.select.horizontalTypeMask", "Horizontal Type Mask Tool", Some("T")), + ("tools.select.verticalTypeMask", "Vertical Type Mask Tool", Some("T")), + ("tools.select.hand", "Hand Tool", Some("H")), + ("tools.select.rotateView", "Rotate View Tool", Some("R")), + ("tools.select.zoom", "Zoom Tool", Some("Z")), + ("tools.select.remove", "Remove Tool", Some("J")), + ("tools.select.spotHealing", "Spot Healing Brush Tool", Some("J")), + ("tools.select.healing", "Healing Brush Tool", Some("J")), + ("tools.select.patch", "Patch Tool", Some("J")), + ("tools.select.contentAwareMove", "Content-Aware Move Tool", Some("J")), + ("tools.select.redEye", "Red Eye Tool", Some("J")), + ("tools.select.cloneStamp", "Clone Stamp Tool", Some("S")), + ("tools.select.patternStamp", "Pattern Stamp Tool", Some("S")), + ("tools.select.historyBrush", "History Brush Tool", Some("Y")), + ("tools.select.blur", "Blur Tool", None), + ("tools.select.sharpen", "Sharpen Tool", None), + ("tools.select.smudge", "Smudge Tool", None), + ("tools.select.dodge", "Dodge Tool", Some("O")), + ("tools.select.burn", "Burn Tool", Some("O")), + ("tools.select.sponge", "Sponge Tool", Some("O")), + ("tools.select.quickSelection", "Quick Selection Tool", Some("W")), + ("tools.select.objectSelection", "Object Selection Tool", Some("W")), + ("tools.select.pen", "Pen Tool", Some("P")), + ("tools.select.freeformPen", "Freeform Pen Tool", Some("P")), + ("tools.select.curvaturePen", "Curvature Pen Tool", Some("P")), + ("tools.select.addAnchorPoint", "Add Anchor Point Tool", Some("P")), + ("tools.select.deleteAnchorPoint", "Delete Anchor Point Tool", Some("P")), + ("tools.select.convertPoint", "Convert Point Tool", Some("P")), + ("tools.select.pathSelection", "Path Selection Tool", Some("A")), + ("tools.select.directSelection", "Direct Selection Tool", Some("A")), + ("tools.select.rectangle", "Rectangle Tool", Some("U")), + ("tools.select.ellipseShape", "Ellipse Tool", Some("U")), + ("tools.select.triangle", "Triangle Tool", Some("U")), + ("tools.select.polygon", "Polygon Tool", Some("U")), + ("tools.select.line", "Line Tool", Some("U")), + ("tools.select.customShape", "Custom Shape Tool", Some("U")), + ("tools.select.slice", "Slice Tool", Some("C")), + ("tools.select.sliceSelect", "Slice Select Tool", Some("C")), + ("tools.select.frame", "Frame Tool", Some("K")), +]; + +/// The default key of tool shortcut `id`, if it's a tool. +pub fn tool_default(id: &str) -> Option> { + TOOLS.iter().find(|t| t.0 == id).map(|t| t.2) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn ids_are_unique_and_keys_are_valid() { + let mut ids: Vec<&str> = SHELL_COMMANDS.iter().map(|c| c.0).chain(TOOLS.iter().map(|t| t.0)).collect(); + let n = ids.len(); + ids.sort_unstable(); + ids.dedup(); + assert_eq!(ids.len(), n, "duplicate ids"); + for (id, _, _, sc) in SHELL_COMMANDS { + assert!(sc.is_none_or(|k| crate::prefs::normalize_shortcut(k).is_some()), "{id}: {sc:?}"); + } + for (id, name, key) in TOOLS { + assert!(id.starts_with("tools.select.") && name.ends_with("Tool"), "{id} {name}"); + assert!(key.is_none_or(|k| k.len() == 1), "{id}: {key:?}"); + } + assert_eq!(tool_default("tools.select.move"), Some(Some("V"))); + assert_eq!(tool_default("edit.copy"), None); + } +} diff --git a/crates/engine/src/shortcut_sets.rs b/crates/engine/src/shortcut_sets.rs index c5fc4993fe..26062def96 100644 --- a/crates/engine/src/shortcut_sets.rs +++ b/crates/engine/src/shortcut_sets.rs @@ -234,20 +234,28 @@ pub fn summarize(set: &str, sections: &[Section], mac: bool) -> String { out } -/// Every engine command (and temporary tool) with a key under the bindings of `p`: (id, -/// label, top-level menu, keys with the main one first). The app's dialog adds the commands -/// only the shell knows (View › Zoom In, the tools). -pub fn bound(p: &Preferences) -> Vec<(String, String, String, Vec)> { - let temporary = crate::prefs::TEMPORARY_TOOLS.iter().map(|t| (t.0, t.1, "Tools", Some(t.2))); - crate::command_specs() - .iter() - .map(|c| (c.id, c.label, c.menu.first().copied().unwrap_or("Other"), c.shortcut)) - .chain(temporary) - .filter_map(|(id, label, menu, def)| { +/// A command (or tool) with a key under some bindings. +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct Bound { + pub id: String, + pub label: String, + /// Its top-level menu ("Tools" for tools). + pub menu: String, + /// Main key first, then the further keys. + pub keys: Vec, + pub tool: bool, +} + +/// Every bindable command with a key under the bindings of `p`: the engine's and the shell's +/// commands, the tools and the temporary tools ([`crate::prefs::bindable_items`]). +pub fn bound(p: &Preferences) -> Vec { + crate::prefs::bindable_items() + .filter_map(|(id, label, menu, def, kind)| { let main = p.shortcut(id, def); let mut keys: Vec = main.and_then(normalize_shortcut).into_iter().collect(); keys.extend(p.alternates(id, main)); - (!keys.is_empty()).then(|| (id.to_string(), label.to_string(), menu.to_string(), keys)) + let menu = menu.first().copied().unwrap_or("Other").to_string(); + (!keys.is_empty()).then(|| Bound { id: id.to_string(), label: label.to_string(), menu, keys, tool: kind == crate::prefs::BindKind::Tool }) }) .collect() } @@ -340,8 +348,12 @@ mod tests { fn bound_lists_main_keys_then_alternates() { let p = prefs(); let b = bound(&p); - let copy = b.iter().find(|(id, ..)| id == "edit.copy").expect("Copy has keys"); - assert_eq!(copy.3, vec!["Cmd+C".to_string(), "F3".to_string()]); - assert!(b.iter().any(|(id, ..)| id == "tools.temporary.hand")); + let copy = b.iter().find(|x| x.id == "edit.copy").expect("Copy has keys"); + assert_eq!(copy.keys, vec!["Cmd+C".to_string(), "F3".to_string()]); + assert!(b.iter().any(|x| x.id == "tools.temporary.hand")); + // The shell's commands and the tools too: a set saved headless is the whole set. + assert!(b.iter().any(|x| x.id == "view.zoomIn" && x.keys.len() >= 2), "Zoom In with its Cmd++"); + let mv = b.iter().find(|x| x.id == "tools.select.move").expect("Move Tool"); + assert!(mv.tool && mv.keys == ["V"] && mv.menu == "Tools"); } } diff --git a/crates/engine/tests/shortcuts_headless.rs b/crates/engine/tests/shortcuts_headless.rs new file mode 100644 index 0000000000..2c8bbd2ca0 --- /dev/null +++ b/crates/engine/tests/shortcuts_headless.rs @@ -0,0 +1,146 @@ +//! Edit › Keyboard Shortcuts without the UI: the shell's commands and the tools are bindable, +//! listed, imported and exported; tools share keys without conflicts; system keys are refused; +//! sets read and write files by path. + +use photocraft_engine::Session; +use serde_json::{Value, json}; + +fn ks(s: &mut Session, p: Value) -> Value { + s.execute("edit.keyboardShortcuts", p).expect("edit.keyboardShortcuts") +} + +fn listed<'a>(r: &'a Value, id: &str) -> &'a Value { + r["commands"].as_array().unwrap().iter().find(|c| c["id"] == id).unwrap_or_else(|| panic!("{id} listed")) +} + +#[test] +fn shell_commands_and_tools_are_bindable_without_the_ui() { + let mut s = Session::new(); + let r = ks(&mut s, json!({"list": true})); + assert_eq!(listed(&r, "view.zoomIn")["alternates"], json!(["Cmd++"]), "the shell's View › Zoom In"); + let mv = listed(&r, "tools.select.move"); + assert_eq!((mv["tool"].clone(), mv["shortcut"].clone(), mv["menu"].clone()), (json!(true), json!("V"), json!(["Tools"]))); + assert!( + r["conflicts"].as_array().unwrap().iter().all(|c| c["commands"] + .as_array() + .unwrap() + .iter() + .any(|id| !id.as_str().unwrap().starts_with("tools.select."))), + "tools sharing a letter cycle, they don't conflict: {}", + r["conflicts"] + ); + // No allowUnknown needed any more. + let r = ks(&mut s, json!({"set": {"tools.select.pencil": "F6", "view.zoomIn": "Cmd+Shift+Z"}})); + assert_eq!((r["overrides"]["tools.select.pencil"].clone(), r["overrides"]["view.zoomIn"].clone()), (json!("F6"), json!("Cmd+Shift+Z"))); +} + +#[test] +fn tools_may_share_a_key_but_a_command_takes_it() { + let mut s = Session::new(); + // Pencil joins the V group: Move and Artboard keep V. + let r = ks(&mut s, json!({"set": {"tools.select.pencil": "V"}})); + assert!(r["overrides"].get("tools.select.move").is_none(), "{}", r["overrides"]); + // A command taking V removes it from every tool that held it. + let r = ks(&mut s, json!({"set": {"edit.copy": "V"}})); + for t in ["tools.select.move", "tools.select.artboard", "tools.select.pencil"] { + assert_eq!(r["overrides"][t], json!(""), "{t}: {}", r["overrides"]); + } +} + +#[test] +fn system_keys_are_refused_and_dropped_from_imports() { + let mut s = Session::new(); + let key = if cfg!(target_os = "macos") { "Cmd+Tab" } else { "Alt+F4" }; + let before = s.prefs().clone(); + assert!(s.execute("edit.keyboardShortcuts", json!({"set": {"edit.copy": key}})).is_err()); + assert!(s.execute("edit.keyboardShortcuts", json!({"alternates": {"edit.copy": ["F9", key]}})).is_err()); + assert_eq!(s.prefs(), &before); + let set = format!( + "F9{key}{key}NV" + ); + let r = ks(&mut s, json!({"importKys": set})); + assert_eq!(r["overrides"]["edit.copy"], json!("F9")); + assert!(r["overrides"].get("edit.cut").is_none(), "the reserved key is left out: {}", r["overrides"]); + assert_eq!(r["import"]["reserved"].as_array().map(Vec::len), Some(1)); + assert_eq!(r["overrides"]["tools.select.pencil"], json!("N"), "tool keys import headless"); + assert!(r["overrides"].get("tools.select.move").is_none(), "Move's own V keeps its cycling"); +} + +#[test] +fn sets_read_and_write_files_and_export_everything() { + let dir = std::env::temp_dir().join(format!("photocraft-shortcuts-{}", std::process::id())); + std::fs::create_dir_all(&dir).unwrap(); + let kys = dir.join("mine.kys"); + let html = dir.join("mine.html"); + let mut s = Session::new(); + ks(&mut s, json!({"set": {"tools.select.pencil": "F6", "edit.copy": "F9"}, "saveSet": "Mine"})); + let r = ks(&mut s, json!({"exportKys": {"path": kys.to_str().unwrap()}, "summarize": {"path": html.to_str().unwrap(), "mac": false}})); + assert!(r.get("kys").is_none() && r["kysPath"].is_string() && r["htmlPath"].is_string(), "{r}"); + let text = std::fs::read_to_string(&kys).unwrap(); + assert!(text.contains("pc-id=\"tools.select.pencil\">F6") && text.contains("pc-id=\"view.zoomIn\""), "the whole set: {text}"); + assert!(std::fs::read_to_string(&html).unwrap().contains("

Tools

")); + // Back to the defaults, then the file restores the set, tools included. + ks(&mut s, json!({"useSet": ""})); + let r = ks(&mut s, json!({"reset": true, "importKys": {"path": kys.to_str().unwrap()}})); + assert_eq!((r["overrides"]["tools.select.pencil"].clone(), r["overrides"]["edit.copy"].clone()), (json!("F6"), json!("F9"))); + // Bad paths and shapes are errors, not panics. + for p in [ + json!({"importKys": {"path": dir.join("missing.kys").to_str().unwrap()}}), + json!({"importKys": {"path": ""}}), + json!({"importKys": 5}), + json!({"exportKys": "yes"}), + json!({"exportKys": {"path": 3}}), + json!({"summarize": {"path": ""}}), + ] { + assert!(s.execute("edit.keyboardShortcuts", p.clone()).is_err(), "{p}"); + } + // A change paired with an export that fails (a folder that doesn't exist, a bad summarize + // value) changes nothing. + let before = ks(&mut s, json!({}))["overrides"].clone(); + for p in [ + json!({"set": {"edit.copy": "F10"}, "exportKys": {"path": dir.join("no").join("such").join("x.kys").to_str().unwrap()}}), + json!({"set": {"edit.copy": "F10"}, "summarize": 7}), + json!({"set": {"edit.copy": "F10"}, "saveSet": "Other", "summarize": {"path": dir.join("no").join("x.html").to_str().unwrap()}}), + // A good .kys path with a bad summarize: neither file is written. + json!({"set": {"edit.copy": "F10"}, "exportKys": {"path": dir.join("both.kys").to_str().unwrap()}, "summarize": {"path": dir.join("no").join("x.html").to_str().unwrap()}}), + json!({"exportKys": {"path": dir.join("both.kys").to_str().unwrap()}, "summarize": 7}), + json!({"exportKys": {"path": dir.join("both.kys").to_str().unwrap()}, "summarize": {"path": dir.to_str().unwrap()}}), + ] { + assert!(s.execute("edit.keyboardShortcuts", p.clone()).is_err(), "{p}"); + let r = ks(&mut s, json!({})); + assert_eq!(r["overrides"], before, "{p}"); + assert!(r["sets"].get("Other").is_none(), "{p}: {r}"); + assert!(!dir.join("both.kys").exists(), "{p}: no file written"); + } + // A second file that passes the checks but can't be written (a NUL in its name): the user's + // existing first file is left exactly as it was, and no staged file stays behind. + let mine = dir.join("existing.kys"); + std::fs::write(&mine, "the user's own file").unwrap(); + let html = format!("{}/a\u{0}.html", dir.to_str().unwrap()); + let p = json!({"exportKys": {"path": mine.to_str().unwrap()}, "summarize": {"path": html}}); + assert!(s.execute("edit.keyboardShortcuts", p.clone()).is_err(), "{p}"); + assert_eq!(std::fs::read_to_string(&mine).unwrap(), "the user's own file"); + let left: Vec<_> = + std::fs::read_dir(&dir).unwrap().filter_map(|e| e.ok()).map(|e| e.file_name()).filter(|n| n.to_string_lossy().contains("photocraft-tmp")).collect(); + assert!(left.is_empty(), "{left:?}"); + let _ = std::fs::remove_dir_all(&dir); +} + +#[test] +fn a_read_only_target_is_refused_before_anything_is_written() { + let dir = std::env::temp_dir().join(format!("photocraft-readonly-{}", std::process::id())); + std::fs::create_dir_all(&dir).unwrap(); + let ro = dir.join("locked.kys"); + std::fs::write(&ro, "locked").unwrap(); + let mut perms = std::fs::metadata(&ro).unwrap().permissions(); + perms.set_readonly(true); + std::fs::set_permissions(&ro, perms.clone()).unwrap(); + let mut s = Session::new(); + let r = s.execute("edit.keyboardShortcuts", json!({"set": {"edit.copy": "F10"}, "exportKys": {"path": ro.to_str().unwrap()}})); + assert!(r.as_ref().is_err_and(|e| e.to_string().contains("read-only")), "{r:?}"); + assert_eq!(std::fs::read_to_string(&ro).unwrap(), "locked"); + #[allow(clippy::permissions_set_readonly_false)] + perms.set_readonly(false); + let _ = std::fs::set_permissions(&ro, perms); + let _ = std::fs::remove_dir_all(&dir); +} diff --git a/crates/ui-egui/src/kys_import.rs b/crates/ui-egui/src/kys_import.rs index 0b9165abd5..75f46a775b 100644 --- a/crates/ui-egui/src/kys_import.rs +++ b/crates/ui-egui/src/kys_import.rs @@ -35,8 +35,7 @@ pub fn plan(app: &PhotocraftApp, set: &KysSet) -> Plan { /// The set's tool keys matched to our tools by name (Photoshop's names are ours): tool /// shortcut id (`tools.select.move`) → letter, and the tools PhotoCraft doesn't have. pub fn plan_tools(set: &KysSet) -> (BTreeMap, Vec) { - let tools: Vec<(String, &str)> = crate::state::Tool::ALL.into_iter().map(|t| (crate::tool_shortcuts::id(t), t.label())).collect(); - photocraft_engine::kys::plan_tools(tools.iter().map(|(id, name)| (id.as_str(), *name)), set) + photocraft_engine::kys::plan_tools(photocraft_engine::shell_commands::TOOLS.iter().map(|t| (t.0, t.1)), set) } /// Put the plan into the open Keyboard Shortcuts dialog (opening one when none is), as diff --git a/crates/ui-egui/src/menus.rs b/crates/ui-egui/src/menus.rs index 7f47e6ea64..2839e51f13 100644 --- a/crates/ui-egui/src/menus.rs +++ b/crates/ui-egui/src/menus.rs @@ -20,91 +20,9 @@ pub(crate) fn modal_allows(app: &PhotocraftApp, id: &str) -> bool { pub const TOP_MENUS: [&str; 10] = ["File", "Edit", "Image", "Layer", "Type", "Select", "Filter", "View", "Window", "Help"]; /// UI-level commands (handled by the shell rather than the engine): id, label, menu, shortcut. -pub const UI_COMMANDS: &[(&str, &str, &[&str], Option<&str>)] = &[ - ("ui.symmetryTransform", "Transform Symmetry", &[], None), - // Right-click the options bar's tool icon (#2825); `tool.resetAll` asks first. - ("tool.reset", "Reset Tool", &[], None), - ("tool.resetAll", "Reset All Tools", &[], None), - ("window.panel.group", "Change Panel Group", &[], None), - ("window.panel.layout", "Change Panel Layout", &[], None), - ("window.panel.move", "Move Panel", &[], None), - ("window.panel.float", "Float Panel", &[], None), - ("window.panel.dock", "Dock Panel", &[], None), - ("window.panel.activate", "Activate Panel", &[], None), - ("window.panel.close", "Close Panel", &[], None), - ("file.open", "Open…", &["File"], Some("Cmd+O")), - ("file.save", "Save", &["File"], Some("Cmd+S")), - ("file.saveAs", "Save As…", &["File"], Some("Cmd+Shift+S")), - ("file.exit", "Exit", &["File"], Some("Cmd+Q")), - ("edit.freeTransform", "Free Transform", &["Edit"], Some("Cmd+T")), - ("file.export.exportAs", "Export As…", &["File", "Export"], Some("Cmd+Alt+Shift+W")), - ("file.export.quickExportAsPng", "Quick Export as PNG", &["File", "Export"], None), - ("edit.transform.scale", "Scale", &["Edit", "Transform"], None), - ("edit.transform.rotate", "Rotate", &["Edit", "Transform"], None), - ("edit.transform.skew", "Skew", &["Edit", "Transform"], None), - ("edit.transform.distort", "Distort", &["Edit", "Transform"], None), - ("edit.transform.perspective", "Perspective", &["Edit", "Transform"], None), - ("select.selectAndMask", "Select and Mask…", &["Select"], Some("Cmd+Alt+R")), - ("view.rulers", "Rulers", &["View"], Some("Cmd+R")), - ("view.show.grid", "Grid", &["View", "Show"], Some("Cmd+'")), - ("view.show.guides", "Guides", &["View", "Show"], Some("Cmd+;")), - ("view.snap", "Snap", &["View"], Some("Cmd+Shift+;")), - ("view.lockGuides", "Lock Guides", &["View"], Some("Cmd+Alt+;")), - ("view.extras", "Extras", &["View"], Some("Cmd+H")), - ("view.show.targetPath", "Target Path", &["View", "Show"], Some("Cmd+Shift+H")), - ("view.screenMode.cycle", "Cycle Screen Mode", &[], Some("F")), - // Photoshop's interface brightness shortcuts: step through the four Pro themes. - ("view.darkenInterface", "Darken Interface", &[], Some("Cmd+Shift+F1")), - ("view.lightenInterface", "Lighten Interface", &[], Some("Cmd+Shift+F2")), - ("edit.freeTransformCopy", "Free Transform a Copy", &[], Some("Cmd+Alt+T")), - ("type.editText", "Edit Type", &[], None), - ("view.zoomIn", "Zoom In", &["View"], Some("Cmd+=")), - ("view.zoomOut", "Zoom Out", &["View"], Some("Cmd+-")), - ("view.fitOnScreen", "Fit on Screen", &["View"], Some("Cmd+0")), - ("view.actualPixels", "100%", &["View"], Some("Cmd+1")), - ("view.rotateView", "Rotate View", &[], None), - ("view.resetView", "Reset View", &["View"], None), - ("window.newWindowForDocument", "New Window for Document", &["Window", "Arrange"], None), - ("window.toggle.layers", "Layers", &["Window"], Some("F7")), - ("window.toggle.history", "History", &["Window"], None), - ("window.toggle.properties", "Properties", &["Window"], None), - ("window.toggle.color", "Color", &["Window"], Some("F6")), - ("window.toggle.brushSettings", "Brush Settings", &["Window"], Some("F5")), - ("window.toggle.brushes", "Brushes", &["Window"], None), - ("window.toggle.navigator", "Navigator", &["Window"], None), - ("window.toggle.toolbar", "Tools", &["Window"], None), - // Photoshop's Tab and ⇧Tab: all panels (Tools, options bar and the dock), or only the dock. - ("window.togglePanels", "Show/Hide All Panels", &[], Some("Tab")), - ("window.toggle.dock", "Show/Hide Panels", &[], Some("Shift+Tab")), - ("window.toggle.options", "Options", &["Window"], None), - // Photoshop's Ctrl+Tab and Ctrl+Shift+Tab, on macOS too (⌘Tab belongs to the system, #2340). - ("window.nextDocument", "Next Document", &[], Some("Ctrl+Tab")), - ("window.previousDocument", "Previous Document", &[], Some("Ctrl+Shift+Tab")), - ("window.theme.toggle", "Next Appearance Mode", &["Window"], None), - ("window.theme.pro", "Pro Theme", &["Window", "Theme"], None), - ("window.theme.proMedium", "Pro Medium Gray Theme", &["Window", "Theme"], None), - ("window.theme.proLightGray", "Pro Light Gray Theme", &["Window", "Theme"], None), - ("window.theme.proLight", "Pro Light Theme", &["Window", "Theme"], None), - ("window.theme.studio", "Studio Theme", &["Window", "Theme"], None), - ("window.theme.studioLight", "Studio Light Theme", &["Window", "Theme"], None), - ("window.theme.classic", "Classic Theme", &["Window", "Theme"], None), - ("window.theme.solarizedDark", "Solarized Dark Theme", &["Window", "Theme"], None), - ("window.theme.adwaita", "Adwaita Light Theme", &["Window", "Theme"], None), - ("window.theme.adwaitaDark", "Adwaita Dark Theme", &["Window", "Theme"], None), - ("window.theme.breezeLight", "Breeze Light Theme", &["Window", "Theme"], None), - ("window.theme.breezeDark", "Breeze Dark Theme", &["Window", "Theme"], None), - ("window.theme.softLight", "Soft Light Theme", &["Window", "Theme"], None), - ("window.theme.softDark", "Soft Dark Theme", &["Window", "Theme"], None), - ("window.theme.system", "Sync with system", &["Window", "Theme"], None), - ("edit.search", "Search…", &["Edit"], Some("Cmd+F")), - ("help.discord", "Join the ArtCraft Discord…", &["Help"], None), - ("help.website", "PhotoCraft Website", &["Help"], None), - ("help.artcraftWebsite", "ArtCraft Website", &["Help"], None), - ("help.github", "PhotoCraft on GitHub", &["Help"], None), - ("help.reportIssue", "Report an Issue…", &["Help"], None), - ("help.systemInfo", "System Info…", &["Help"], None), - ("help.about", "About PhotoCraft", &["Help"], None), -]; +/// The table is the engine's ([`photocraft_engine::shell_commands::SHELL_COMMANDS`]), so the +/// commands and their keys exist without this UI (headless shortcut sets, lists, conflicts). +pub const UI_COMMANDS: &[(&str, &str, &[&str], Option<&str>)] = photocraft_engine::shell_commands::SHELL_COMMANDS; /// Window › : the open documents, listed at the end of the Window menu as in Photoshop. /// The id ends in the document's index, which is its tab's position. diff --git a/crates/ui-egui/src/prefs_ui.rs b/crates/ui-egui/src/prefs_ui.rs index 795ea84cea..b59e7803f5 100644 --- a/crates/ui-egui/src/prefs_ui.rs +++ b/crates/ui-egui/src/prefs_ui.rs @@ -816,33 +816,7 @@ fn conflict_state(overrides: &BTreeMap, alternates: &BTreeMap bool { - let Some(n) = prefs::normalize_shortcut(sc) else { return false }; - let reserved: &[&str] = if cfg!(target_os = "macos") { - &["Cmd+Tab", "Cmd+Shift+Tab", "Cmd+Alt+Escape", "Cmd+`"] - } else if cfg!(target_os = "windows") { - &["Alt+Tab", "Alt+Shift+Tab", "Alt+F4", "Cmd+Alt+Delete", "Cmd+Escape", "Cmd+Shift+Escape"] - } else { - // Linux and the BSDs: window switching and closing, and the virtual consoles. - &[ - "Alt+Tab", - "Alt+Shift+Tab", - "Alt+F4", - "Cmd+Alt+Delete", - "Cmd+Alt+F1", - "Cmd+Alt+F2", - "Cmd+Alt+F3", - "Cmd+Alt+F4", - "Cmd+Alt+F5", - "Cmd+Alt+F6", - "Cmd+Alt+F7", - "Cmd+Alt+F8", - "Cmd+Alt+F9", - "Cmd+Alt+F10", - "Cmd+Alt+F11", - "Cmd+Alt+F12", - ] - }; - reserved.iter().filter_map(|r| prefs::normalize_shortcut(r)).any(|r| r == n) + prefs::reserved_shortcut(sc) } /// Text of a key press for the shortcut editor (`Cmd+Shift+K`), `None` for bare modifiers. diff --git a/crates/ui-egui/src/tool_shortcuts.rs b/crates/ui-egui/src/tool_shortcuts.rs index 345b7f57b3..91f66a0f07 100644 --- a/crates/ui-egui/src/tool_shortcuts.rs +++ b/crates/ui-egui/src/tool_shortcuts.rs @@ -94,3 +94,17 @@ mod tests { assert_eq!(h.state().ui.status, "q"); } } + +#[cfg(test)] +mod engine_table_tests { + use super::*; + + /// The engine knows the tools as data (headless shortcut sets); it must match the toolbar. + #[test] + fn the_engines_tool_table_matches_the_tools() { + let ours: Vec<(String, &str, Option)> = Tool::ALL.into_iter().map(|t| (id(t), t.label(), default(t))).collect(); + let engine: Vec<(String, &str, Option)> = + photocraft_engine::shell_commands::TOOLS.iter().map(|(i, n, k)| (i.to_string(), *n, k.map(str::to_string))).collect(); + assert_eq!(ours, engine, "update photocraft_engine::shell_commands::TOOLS"); + } +} diff --git a/docs/control-protocol.md b/docs/control-protocol.md index 2bbcab2469..14d416f19b 100644 --- a/docs/control-protocol.md +++ b/docs/control-protocol.md @@ -228,7 +228,7 @@ the same commands work in the app, the CLI and headless MCP. Paths are dotted ca | `prefs.set` | `{"path":"cursors.painting","value":"precise"}` or `{"values":{"unitsAndRulers.rulers":"cm","guidesGridAndSlices.gridColor":"#ff8800"}}`. Values are validated (choices, ranges, `#rrggbb` colours, shortcut syntax); a batch applies all or nothing. A section path takes an object and merges it key by key | | `prefs.reset` | `{"path":"performance"}` (a section or key); no path resets everything. An individual `shortcuts.` or `menus.colors.` reset removes the override and returns `null`, including when it is already absent | | `edit.preferences.
` | the section's values; in the app (no params) it opens the Preferences dialog on that section | -| `edit.keyboardShortcuts` | `{"set":{"edit.fill":"Cmd+Shift+F"},"reset":true\|["id",…],"removeConflicts":true,"filter":"blur","list":false,"importKys":"<.kys XML>"}`: returns overrides, matching commands and conflicts. A shortcut moved to another command is removed from its old owner unless `removeConflicts` is false. `""` removes a shortcut, `null` restores the default. The held temporary tools are bindable too: `tools.temporary.hand` (Space; also repositions a selection being drawn), `tools.temporary.zoomIn` (Cmd+Space), `tools.temporary.zoomOut` (Alt+Space; Alt added to a held Zoom In, Cmd+Alt+Space, zooms out too), `tools.temporary.erase` (`` ` ``; with the Brush or Pencil, a stroke started while it is held is in Clear mode, `paint.stroke`/`paint.pencil` `"mode": "clear"`, erasing with that tool's brush); they list with `"hold": true`. ⌘ (Ctrl off the Mac) held alone is the Move tool with the painting, retouching and other non-selection tools, as in Photoshop; it is a modifier, not a binding. `importKys` applies a Photoshop `.kys` set (the file's XML text, at most 4 MiB): rows are matched to commands by our id (`pc-id`, in sets PhotoCraft exported) or else by menu label, a key equal to the default restores it, keys also given in `set` win, and the result gains `import` (`name`, `imported`, `set`, `unknown`, `unreadable`, `alternates`, `toolKeys`, `windows`). A set saved by Photoshop on Windows (no ⌘ or Opt anywhere, Ctrl used) reads Ctrl as the primary modifier, so `Ctrl+L` is the `Cmd+L` default (`windows: true`). Tool keys are matched to tools by name in the app's dialog import (a tool's own letter keeps its Shift cycling); this command doesn't know the tools and leaves them. In the app it opens Keyboard Shortcuts and Menus, whose Import Shortcuts… button loads a `.kys` set into the dialog (opening or dropping a `.kys`, or Photoshop's live `Keyboard Shortcuts.psp`, does the same). On a desktop with Photoshop installed, the first launch that has no shortcut overrides yet asks whether to import the newest install's live set (Import / Don't Import; `ui.inspect` shows it as `kysOffer`); the answer is recorded in the preferences as `dialogs.photoshopShortcuts`, so it is asked once. **Sets** (Edit › Keyboard Shortcuts › Set): `useSet` loads a saved set's bindings (`""` or `"PhotoCraft Defaults"`: the defaults) before the call's other changes; then `saveSet` saves the bindings as they end up under a name and uses it, `deleteSet` removes one (the bindings stay), `sets` replaces every saved set (`{"": {"shortcuts": {…}, "alternateShortcuts": {…}}}`, the dialog's OK) and `activeSet` names the set in use without loading it. Names are trimmed, 1–64 characters, at most 64 sets. Every reply has `sets`, `activeSet` (`""` for the defaults), `defaultsName` and `modified` (the bindings differ from the set in use). `exportKys: true` adds `kys`, the bindings of the engine's commands as a `.kys` file (Photoshop's macOS key spelling; each row carries our id as `pc-id`, so the file imports back exactly); `summarize: true` (or `{"mac": false}`) adds `html`, Photoshop's Summarize page. The app's dialog exports and summarizes the shell's commands and tools as well. A bad parameter anywhere in the call changes nothing | +| `edit.keyboardShortcuts` | `{"set":{"edit.fill":"Cmd+Shift+F"},"reset":true\|["id",…],"removeConflicts":true,"filter":"blur","list":false,"importKys":"<.kys XML>"}`: returns overrides, matching commands and conflicts. A shortcut moved to another command is removed from its old owner unless `removeConflicts` is false. `""` removes a shortcut, `null` restores the default. The held temporary tools are bindable too: `tools.temporary.hand` (Space; also repositions a selection being drawn), `tools.temporary.zoomIn` (Cmd+Space), `tools.temporary.zoomOut` (Alt+Space; Alt added to a held Zoom In, Cmd+Alt+Space, zooms out too), `tools.temporary.erase` (`` ` ``; with the Brush or Pencil, a stroke started while it is held is in Clear mode, `paint.stroke`/`paint.pencil` `"mode": "clear"`, erasing with that tool's brush); they list with `"hold": true`. ⌘ (Ctrl off the Mac) held alone is the Move tool with the painting, retouching and other non-selection tools, as in Photoshop; it is a modifier, not a binding. `importKys` applies a Photoshop `.kys` set (the file's XML text, at most 4 MiB): rows are matched to commands by our id (`pc-id`, in sets PhotoCraft exported) or else by menu label, a key equal to the default restores it, keys also given in `set` win, and the result gains `import` (`name`, `imported`, `set`, `unknown`, `unreadable`, `alternates`, `toolKeys`, `windows`). A set saved by Photoshop on Windows (no ⌘ or Opt anywhere, Ctrl used) reads Ctrl as the primary modifier, so `Ctrl+L` is the `Cmd+L` default (`windows: true`). Tool keys are matched to tools by `pc-id` or name (a tool's own letter keeps its Shift cycling); `import.tools` lists the keys given. In the app it opens Keyboard Shortcuts and Menus, whose Import Shortcuts… button loads a `.kys` set into the dialog (opening or dropping a `.kys`, or Photoshop's live `Keyboard Shortcuts.psp`, does the same). On a desktop with Photoshop installed, the first launch that has no shortcut overrides yet asks whether to import the newest install's live set (Import / Don't Import; `ui.inspect` shows it as `kysOffer`); the answer is recorded in the preferences as `dialogs.photoshopShortcuts`, so it is asked once. **Sets** (Edit › Keyboard Shortcuts › Set): `useSet` loads a saved set's bindings (`""` or `"PhotoCraft Defaults"`: the defaults) before the call's other changes; then `saveSet` saves the bindings as they end up under a name and uses it, `deleteSet` removes one (the bindings stay), `sets` replaces every saved set (`{"": {"shortcuts": {…}, "alternateShortcuts": {…}}}`, the dialog's OK) and `activeSet` names the set in use without loading it. Names are trimmed, 1–64 characters, at most 64 sets. Every reply has `sets`, `activeSet` (`""` for the defaults), `defaultsName` and `modified` (the bindings differ from the set in use). `exportKys: true` adds `kys`, the bindings of every bindable command and tool as a `.kys` file (Photoshop's macOS key spelling; each row carries our id as `pc-id`, so the file imports back exactly); `summarize: true` (or `{"mac": false}`) adds `html`, Photoshop's Summarize page. A bad parameter anywhere in the call, an export path that can't be written included, changes nothing. **Headless parity:** the shell's commands (View › Zoom In, the panels; `photocraft_engine::shell_commands::SHELL_COMMANDS`) and the tools (`tools.select.`, listed with `"tool": true`) are bindable, listed, imported, exported and checked for conflicts without the UI; tools may share a key (they cycle with Shift, so a shared letter is no conflict), while a command given a tool's key takes it from the tools. Keys the system takes first (Alt+Tab, Alt+F4, Ctrl+Alt+Delete everywhere but the Mac, plus Ctrl+Esc and Ctrl+Shift+Esc on Windows and Ctrl+Alt+F1–F12 on Linux; ⌘Tab, ⌘`, ⌘⌥Esc on the Mac) are refused, and left out of an imported set (`import.reserved`). Files: `importKys: {"path": …}` reads a `.kys`, `exportKys: {"path": …}` and `summarize: {"path": …}` write the file (the reply has `kysPath` / `htmlPath`); automation (MCP) refuses these paths, as it does `actions.save`'s | | `edit.menus` / `edit.toolbar` | `{"hide":["edit.fade"],"show":[…],"color":{"edit.fill":"red"},"reset":false}` / `{"hidden":["Sponge"],"order":[…]}` | | `edit.colorSettings` | `{"workingRgb":"display-p3","workingCmyk":"coated-cmyk","workingGray":"sgray","policyRgb":"preserve\|convert\|off",…,"askOnMismatch":true,"intent":"perceptual","bpc":true}`; honoured when opening files (`file.openAs`, the app's File › Open) and by Image › Mode and "working" profile specs. `color.profileMismatch {"action":"preserve\|convert\|discard\|assignWorking"}` answers the mismatch prompt | From fdaa9b8316be77ab69538654e3df44f922484acf Mon Sep 17 00:00:00 2001 From: Tanish Manku Date: Thu, 15 Oct 2026 15:26:13 -0700 Subject: [PATCH 36/47] Free Transform: New Layer applies the transform first; tests for switching tool, layer or New Layer (#5665, #5667) (#5859) * Tests: Free Transform applies when another tool, layer or New Layer is picked (#5665, #5667) Whole-app regression tests for the paths #5665 and #5667 report (a moved box, and a placed image moved or untouched; toolbar tool click, Layers panel row click, New Layer). They pass on main, so these issues don't reproduce here; the tests pin Photoshop's behaviour (apply, never silently cancel) while the reporters narrow down their environment. * New Layer while Free Transform is open applies the transform first (History: the transform, then New Layer; a placed layer's step no longer swallows New Layer); tests: a real placed fixture, the New Layer button, History order * Free Transform + New Layer: check the command can run before applying the transform; a refused transform keeps the box and the placed layer and returns the refusal * Free Transform + New Layer: Session::precheck validates the call (params shape, New Layer's values, precondition) before the box is applied * Free Transform + New Layer: commit-first only for PRECHECKED commands; precheck shares dispatch's admission checks (named-layer precondition, jobs, hidden target) and checks a named layer exists; Group from Layers documents its layer param; test: precheck agrees with execute * Free Transform + New Layer: precheck resolves a layer given by name as the command does; Layer via Copy/Cut leave PRECHECKED (their failures show only when they run); rebased (lib.rs mod lines) --- crates/engine/src/jobs.rs | 12 +- crates/engine/src/layer_multi_cmds.rs | 10 +- crates/engine/src/lib.rs | 53 +++++ crates/engine/src/new_layer.rs | 64 ++++-- crates/engine/tests/precheck.rs | 74 ++++++ crates/ui-egui/src/lib.rs | 17 ++ crates/ui-egui/src/transform_switch_tests.rs | 223 +++++++++++++++++++ 7 files changed, 418 insertions(+), 35 deletions(-) create mode 100644 crates/engine/tests/precheck.rs create mode 100644 crates/ui-egui/src/transform_switch_tests.rs diff --git a/crates/engine/src/jobs.rs b/crates/engine/src/jobs.rs index 527bc6f22b..70b95f2908 100644 --- a/crates/engine/src/jobs.rs +++ b/crates/engine/src/jobs.rs @@ -671,17 +671,7 @@ impl Session { return Ok(Started::Done(v)); } // Pixel commands follow the Channels panel target unless the caller names one. - let run_params = crate::channel_cmds::inject_target(self, id, crate::commands::inject_kind(id, params.clone())); - if let Err(why) = self.explicit_layer_enabled(spec, ¶ms).unwrap_or_else(|| self.precondition(spec, &run_params)) { - return Err(EngineError::Disabled(id.to_string(), why)); - } - if let Some(why) = self.job_conflict(id, spec.journal) { - return Err(EngineError::Disabled(id.to_string(), why)); - } - // Painting on or moving a hidden layer is refused, as in Photoshop (#571). - if let Some(why) = crate::hidden_target::refusal(self, id, ¶ms) { - return Err(EngineError::Other(why.into())); - } + let run_params = self.admit(id, spec, ¶ms)?; self.coalesce_request = params.get("coalesce").and_then(Value::as_str).map(str::to_string); self.color_restrict = crate::channel_cmds::color_restriction(self, id, &run_params); self.jobs.spawn = background; diff --git a/crates/engine/src/layer_multi_cmds.rs b/crates/engine/src/layer_multi_cmds.rs index 87bb507e4d..1b71e966a4 100644 --- a/crates/engine/src/layer_multi_cmds.rs +++ b/crates/engine/src/layer_multi_cmds.rs @@ -1487,7 +1487,15 @@ pub fn specs() -> Vec { link_layers(s) }), spec!("layer.mergeLayers", "Merge Layers", &["Layer"], Some("Cmd+E"), "{} (one layer selected: Merge Down)", has_layer, |s, _| merge_layers(s)), - spec!("layer.new.groupFromLayers", "Group from Layers…", &["Layer", "New"], None, r##"{"name":str?}"##, has_layer, group_layers), + spec!( + "layer.new.groupFromLayers", + "Group from Layers…", + &["Layer", "New"], + None, + r##"{"layer":id?,"name":str?} (no layer: every selected layer)"##, + has_layer, + group_layers + ), spec!("layer.arrange.reverse", "Reverse", &["Layer", "Arrange"], None, "{}", two_plus, |s, _| reverse(s)), spec!( "layer.lockLayers", diff --git a/crates/engine/src/lib.rs b/crates/engine/src/lib.rs index 41915c544d..e45826f34a 100644 --- a/crates/engine/src/lib.rs +++ b/crates/engine/src/lib.rs @@ -422,6 +422,12 @@ pub struct CopiedStyle { pub mode: photocraft_color::ColorMode, } +/// The commands whose [`Session::precheck`] is complete: it accepts exactly what `execute` does +/// (Free Transform applies its box before these, and only when they will run). Layer via Copy and +/// Cut aren't: whether they have anything to copy (a selection over transparent pixels, a group +/// or fill layer) is known only when they run. +pub const PRECHECKED: &[&str] = &["layer.new.layer", "layer.new.group", "layer.new.groupFromLayers", "layer.new.layerFromBackground"]; + #[derive(Default)] pub struct Session { docs: Vec, @@ -660,6 +666,53 @@ impl Session { self.live_commit = None; } + /// Check a call without running it: the command exists, its params have the shape + /// [`Self::execute`] requires (New Layer's fully), and it can run now. For a caller that does + /// something first only when the command will go through: Free Transform applies its box + /// before a new layer is made. + /// + /// Complete for the commands in [`PRECHECKED`]: there it accepts exactly what `execute` + /// accepts (a test holds the two together). For others it checks only what they share. + pub fn precheck(&mut self, id: &str, params: &Value) -> Result<()> { + let spec = crate::commands::find(id).ok_or_else(|| EngineError::UnknownCommand(id.to_string()))?; + if !(params.is_object() || params.is_null()) { + return Err(EngineError::BadParams { cmd: id.to_string(), msg: "params must be a JSON object".into() }); + } + crate::param_check::check(spec, params)?; + crate::filters::validate_params(id, params)?; + if id == "layer.new.layer" { + crate::new_layer::parse(params)?; + } + // A command that takes `"layer"` (an id or a name, as it resolves it) fails on one the + // document doesn't have. + if spec.params.contains("\"layer\":id") + && let Some(l) = commands::explicit_layer(self, params)? + && self.active().is_none_or(|st| st.doc.layer(l).is_none()) + { + return Err(EngineError::BadParams { cmd: id.to_string(), msg: format!("no layer {}", l.0) }); + } + self.admit(id, spec, params).map(|_| ()) + } + + /// What `dispatch` checks once the params are valid and a floating selection has dropped: + /// the precondition (on a named layer for commands that take one), a job running on the + /// document, a hidden target. The run params (the Channels panel's target filled in) when it + /// may run. + pub(crate) fn admit(&mut self, id: &str, spec: &commands::CommandSpec, params: &Value) -> Result { + let run_params = channel_cmds::inject_target(self, id, commands::inject_kind(id, params.clone())); + if let Err(why) = self.explicit_layer_enabled(spec, params).unwrap_or_else(|| self.precondition(spec, &run_params)) { + return Err(EngineError::Disabled(id.to_string(), why)); + } + if let Some(why) = self.job_conflict(id, spec.journal) { + return Err(EngineError::Disabled(id.to_string(), why)); + } + // Painting on or moving a hidden layer is refused, as in Photoshop (#571). + if let Some(why) = hidden_target::refusal(self, id, params) { + return Err(EngineError::Other(why.into())); + } + Ok(run_params) + } + /// Is the command currently runnable? (drives menu enablement) pub fn is_enabled(&self, id: &str) -> bool { self.is_enabled_with(id, &Value::Null) diff --git a/crates/engine/src/new_layer.rs b/crates/engine/src/new_layer.rs index a6f4cefd38..cb1b151aba 100644 --- a/crates/engine/src/new_layer.rs +++ b/crates/engine/src/new_layer.rs @@ -24,9 +24,49 @@ pub fn neutral_color(mode: BlendMode) -> Option<([f32; 3], &'static str)> { } } +/// A New Layer call's params, checked. +pub(crate) struct NewLayer { + clip: bool, + mode: BlendMode, + opacity: f32, + label: LabelColor, + neutral: Option<[f32; 3]>, + below: bool, + name: Option, +} + /// `layer.new.layer`: a pixel layer above the active one (below it with `position: below`, the /// Layers panel's ⌘/Ctrl-click), joining the clip group it lands in. pub fn run(s: &mut Session, p: &Value) -> Result { + let NewLayer { clip, mode, opacity, label, neutral, below, name } = parse(p)?; + let id = s.edit("New Layer", |doc, active| { + let name = name.clone().unwrap_or_else(|| doc.next_layer_name("Layer")); + let mut layer = Layer::raster(name, doc.pixel_format()); + layer.blend = mode; + layer.opacity = opacity; + layer.label = label; + if let Some(rgb) = neutral { + let bounds = doc.bounds(); + let surface = layer.surface_mut().ok_or_else(|| bad("a new pixel layer has no pixels"))?; + let px = photocraft_raster::from_rgba(&surface.format(), [rgb[0], rgb[1], rgb[2], 1.0]); + surface.fill_rect(bounds, &px); + } + // Nothing may sit under the Background: below it the layer lands above it instead. + let id = match *active { + Some(a) if below && doc.layer(a).is_some() && !doc.is_background(a) => doc.insert_below(Some(a), layer), + _ => doc.insert_above(*active, layer), + }; + crate::clip_group::settle(doc, &[id], &Default::default(), clip); + *active = Some(id); + Ok(id) + })?; + crate::layer_multi_cmds::note_inert_insert(s, id); + Ok(json!({ "layer": id.0 })) +} + +/// Check a New Layer call's params without running it (Free Transform applies its box before a +/// new layer only when the new layer can be made). +pub(crate) fn parse(p: &Value) -> Result { let flag = |k: &str| match p.get(k) { None | Some(Value::Null) => Ok(false), Some(v) => v.as_bool().ok_or_else(|| bad(format!("`{k}` must be true or false"))), @@ -62,29 +102,7 @@ pub fn run(s: &mut Session, p: &Value) -> Result { None | Some(Value::Null) => None, Some(v) => Some(v.as_str().ok_or_else(|| bad("`name` must be a string"))?.to_string()), }; - let id = s.edit("New Layer", |doc, active| { - let name = name.clone().unwrap_or_else(|| doc.next_layer_name("Layer")); - let mut layer = Layer::raster(name, doc.pixel_format()); - layer.blend = mode; - layer.opacity = opacity; - layer.label = label; - if let Some(rgb) = neutral { - let bounds = doc.bounds(); - let surface = layer.surface_mut().ok_or_else(|| bad("a new pixel layer has no pixels"))?; - let px = photocraft_raster::from_rgba(&surface.format(), [rgb[0], rgb[1], rgb[2], 1.0]); - surface.fill_rect(bounds, &px); - } - // Nothing may sit under the Background: below it the layer lands above it instead. - let id = match *active { - Some(a) if below && doc.layer(a).is_some() && !doc.is_background(a) => doc.insert_below(Some(a), layer), - _ => doc.insert_above(*active, layer), - }; - crate::clip_group::settle(doc, &[id], &Default::default(), clip); - *active = Some(id); - Ok(id) - })?; - crate::layer_multi_cmds::note_inert_insert(s, id); - Ok(json!({ "layer": id.0 })) + Ok(NewLayer { clip, mode, opacity, label, neutral, below, name }) } #[cfg(test)] diff --git a/crates/engine/tests/precheck.rs b/crates/engine/tests/precheck.rs new file mode 100644 index 0000000000..a28726dfeb --- /dev/null +++ b/crates/engine/tests/precheck.rs @@ -0,0 +1,74 @@ +//! `Session::precheck` accepts exactly what `execute` accepts for the commands it covers +//! (`PRECHECKED`): Free Transform applies its box before them only when they will run (#5859). + +use photocraft_engine::{PRECHECKED, Session}; +use serde_json::{Value, json}; + +/// Documents in different states: a Background only, a pixel layer active, a group active, a +/// selection, no document. +fn states() -> Vec<(&'static str, Session)> { + let mut out = Vec::new(); + out.push(("no document", Session::new())); + let mut s = Session::new(); + s.execute("file.new", json!({"width": 40, "height": 30, "background": "white"})).unwrap(); + out.push(("background only", s)); + let mut s = Session::new(); + s.execute("file.new", json!({"width": 40, "height": 30, "background": "white"})).unwrap(); + s.execute("layer.new.layer", json!({})).unwrap(); + s.execute("edit.fill", json!({"color": "#ff0000"})).unwrap(); + out.push(("pixel layer", s)); + let mut s = Session::new(); + s.execute("file.new", json!({"width": 40, "height": 30, "background": "transparent"})).unwrap(); + s.execute("layer.new.group", json!({})).unwrap(); + out.push(("group, no background", s)); + let mut s = Session::new(); + s.execute("file.new", json!({"width": 40, "height": 30, "background": "white"})).unwrap(); + s.execute("layer.new.layer", json!({})).unwrap(); + s.execute("edit.fill", json!({"color": "#00ff00"})).unwrap(); + s.execute("select.rect", json!({"x": 2, "y": 2, "width": 10, "height": 10})).unwrap(); + out.push(("selection", s)); + out +} + +fn params() -> Vec { + vec![ + Value::Null, + json!({}), + json!([1]), + json!("x"), + json!({"mode": "Bogus"}), + json!({"mode": "Multiply"}), + json!({"fillNeutral": true}), + json!({"mode": "Overlay", "fillNeutral": true}), + json!({"position": "x"}), + json!({"position": "below"}), + json!({"opacity": "half"}), + json!({"name": 5}), + json!({"name": "ok"}), + json!({"color": "chartreuse"}), + json!({"layer": 999_999}), + json!({"layer": "Layer 1"}), + json!({"layer": "no such layer"}), + json!({"layer": true}), + json!({"ifPresent": true}), + ] +} + +#[test] +fn precheck_agrees_with_execute() { + let mut disagreements = Vec::new(); + for id in PRECHECKED { + for p in params() { + for (state, _) in states() { + // A fresh copy of the state for each call: execute changes it. + let mut s = states().into_iter().find(|(n, _)| *n == state).map(|(_, s)| s).unwrap(); + let pre = s.precheck(id, &p).is_ok(); + let ran = s.execute(id, p.clone()).is_ok(); + if pre != ran { + disagreements.push(format!("{id} {p} on {state}: precheck {pre}, execute {ran}")); + } + } + } + } + assert!(disagreements.is_empty(), "{disagreements:#?}"); +} diff --git a/crates/ui-egui/src/lib.rs b/crates/ui-egui/src/lib.rs index 34b346af00..27a2004012 100644 --- a/crates/ui-egui/src/lib.rs +++ b/crates/ui-egui/src/lib.rs @@ -1052,6 +1052,21 @@ impl PhotocraftApp { { authorize(id, ¶ms)?; } + // A new layer while Free Transform is open applies the transform first, as Photoshop does + // (#5667): History reads the transform (or Place Embedded), then New Layer. Only for a + // command that can run; a transform refused or still being applied keeps the box open and + // the command doesn't run. + if photocraft_engine::PRECHECKED.contains(&id) && self.ui.transform.is_some() { + // Bad params or a command that can't run leave the box as it is. + self.session.precheck(id, ¶ms).map_err(|e| e.to_string())?; + transform_tool::commit(self); + if transform_tool::committing(self).is_some() { + return Err("the transform is still being applied; try again when it has finished".into()); + } + if self.ui.transform.is_some() { + return Err(if self.ui.status.is_empty() { "the transform couldn't be applied".into() } else { self.ui.status.clone() }); + } + } if let Some(result) = panel_docking::command(self, id, ¶ms) { return result; } @@ -2667,6 +2682,8 @@ mod transform_fx_tests; #[cfg(test)] mod layer_props_ui_tests; #[cfg(test)] +mod transform_switch_tests; +#[cfg(test)] mod transform_undo_tests; #[cfg(test)] diff --git a/crates/ui-egui/src/transform_switch_tests.rs b/crates/ui-egui/src/transform_switch_tests.rs new file mode 100644 index 0000000000..f5be113d2a --- /dev/null +++ b/crates/ui-egui/src/transform_switch_tests.rs @@ -0,0 +1,223 @@ +//! Free Transform with the whole app: picking another tool or layer, or New Layer, while the box +//! is open applies the transform as it stands, as Photoshop does (#5441, #5665, #5667). + +use egui_kittest::Harness; +use egui_kittest::kittest::Queryable; +use serde_json::json; + +use crate::PhotocraftApp; + +/// The app with a 400 × 300 document: a red 100 × 60 rectangle at (100, 100) on its own layer +/// above the Background. +fn app() -> Harness<'static, PhotocraftApp> { + let mut h = Harness::builder().with_size(egui::vec2(1400.0, 900.0)).with_max_steps(64).build_eframe(|cc| { + PhotocraftApp::setup_context(&cc.egui_ctx, Default::default()); + let mut app = PhotocraftApp::new(photocraft_engine::Session::new(), crate::Services::default()); + app.run("file.new", json!({"width": 400, "height": 300, "background": "white"})).unwrap(); + app.run("layer.new.layer", json!({})).unwrap(); + app.run("select.rect", json!({"x": 100, "y": 100, "width": 100, "height": 60})).unwrap(); + app.run("edit.fill", json!({"color": "#ff0000"})).unwrap(); + app.run("select.deselect", json!({})).unwrap(); + app + }); + h.run_steps(6); + h +} + +/// Open Free Transform and move the box 50 px right, 40 px down. +fn transform_and_move(h: &mut Harness<'static, PhotocraftApp>) { + let ctx = h.ctx.clone(); + crate::menus::invoke(h.state_mut(), &ctx, "edit.freeTransform", json!({})).unwrap(); + h.run_steps(2); + let t = h.state_mut().ui.transform.as_mut().expect("Free Transform is open"); + for p in &mut t.quad { + p[0] += 50.0; + p[1] += 40.0; + } + h.run_steps(2); +} + +fn red_bounds(h: &Harness<'static, PhotocraftApp>) -> photocraft_geom::Rect { + let st = h.state().session.active().unwrap(); + let top = st.doc.layers.last().unwrap(); + top.surface().unwrap().content_bounds() +} + +#[test] +fn picking_another_tool_in_the_toolbar_applies_the_transform() { + let mut h = app(); + transform_and_move(&mut h); + let hand = h.query_by_label_contains("Hand Tool").expect("the toolbar's Hand Tool"); + hand.click(); + h.run_steps(4); + assert!(h.state().ui.transform.is_none(), "the box closed"); + assert_eq!(red_bounds(&h), photocraft_geom::Rect::new(150, 140, 250, 200), "the move was applied, not cancelled"); +} + +/// The app with the red rectangle on a layer the last step made (Layer via Copy, which deselects in +/// the same step), as Place Embedded leaves it for the box `begin_placed` opens: cancelling the +/// place takes that step back. +fn placed_app() -> Harness<'static, PhotocraftApp> { + let mut h = Harness::builder().with_size(egui::vec2(1400.0, 900.0)).with_max_steps(64).build_eframe(|cc| { + PhotocraftApp::setup_context(&cc.egui_ctx, Default::default()); + let mut app = PhotocraftApp::new(photocraft_engine::Session::new(), crate::Services::default()); + app.run("file.new", json!({"width": 400, "height": 300, "background": "white"})).unwrap(); + app.run("select.rect", json!({"x": 100, "y": 100, "width": 100, "height": 60})).unwrap(); + app.run("edit.fill", json!({"color": "#ff0000"})).unwrap(); + app.run("layer.new.layerViaCopy", json!({})).unwrap(); + app + }); + h.run_steps(6); + assert!(h.state().session.active().unwrap().doc.selection.is_none(), "Layer via Copy deselects in its own step"); + h +} + +fn history(h: &Harness<'static, PhotocraftApp>) -> Vec { + h.state().session.active().unwrap().history.entries().to_vec() +} + +/// The box on a just-placed layer (a file dropped on the canvas), moved. +fn placed_and_moved(h: &mut Harness<'static, PhotocraftApp>) { + let ctx = h.ctx.clone(); + crate::transform_tool::begin_placed(h.state_mut(), &ctx).unwrap(); + h.run_steps(2); + let t = h.state_mut().ui.transform.as_mut().expect("Free Transform is open"); + for p in &mut t.quad { + p[0] += 50.0; + p[1] += 40.0; + } + h.run_steps(2); +} + +#[test] +fn a_placed_image_is_applied_when_another_tool_is_picked() { + let mut h = placed_app(); + placed_and_moved(&mut h); + h.query_by_label_contains("Hand Tool").expect("the toolbar's Hand Tool").click(); + h.run_steps(4); + assert!(h.state().ui.transform.is_none()); + assert_eq!(h.state().session.active().unwrap().doc.layers.len(), 2, "the placed layer stays"); + assert_eq!(red_bounds(&h), photocraft_geom::Rect::new(150, 140, 250, 200)); +} + +#[test] +fn picking_another_layer_in_the_panel_applies_the_transform() { + for placed in [false, true] { + let mut h = if placed { placed_app() } else { app() }; + if placed { + placed_and_moved(&mut h); + } else { + transform_and_move(&mut h); + } + h.get_by_label("Background").click(); + h.run_steps(4); + assert!(h.state().ui.transform.is_none(), "placed {placed}"); + assert_eq!(h.state().session.active().unwrap().doc.layers.len(), 2, "placed {placed}"); + assert_eq!(red_bounds(&h), photocraft_geom::Rect::new(150, 140, 250, 200), "placed {placed}"); + } +} + +#[test] +fn new_layer_applies_the_transform_first() { + for placed in [false, true] { + let mut h = if placed { placed_app() } else { app() }; + let before = history(&h); + if placed { + placed_and_moved(&mut h); + } else { + transform_and_move(&mut h); + } + // The Layers panel's New Layer button (the menu item opens the New Layer dialog). + h.get_by_label("Create a new layer").click(); + h.run_steps(4); + assert!(h.state().ui.transform.is_none(), "placed {placed}"); + let st = h.state().session.active().unwrap(); + assert_eq!(st.doc.layers.len(), 3, "placed {placed}"); + let red = st.doc.layers[1].surface().unwrap().content_bounds(); + assert_eq!(red, photocraft_geom::Rect::new(150, 140, 250, 200), "placed {placed}: the moved image, not the original"); + // History: the transform (folded into Place Embedded for a placed layer), then New Layer. + let after = history(&h); + let added: Vec<&str> = after.iter().skip(before.len().saturating_sub(usize::from(placed))).map(String::as_str).collect(); + let first = if placed { photocraft_engine::file_cmds::PLACE_EMBEDDED } else { "Free Transform" }; + assert_eq!(added, [first, "New Layer"], "placed {placed}: {after:?}"); + } +} + +#[test] +fn an_untouched_placed_image_stays_when_another_tool_or_layer_is_picked() { + for via_layer in [false, true] { + let mut h = placed_app(); + let ctx = h.ctx.clone(); + crate::transform_tool::begin_placed(h.state_mut(), &ctx).unwrap(); + h.run_steps(2); + if via_layer { + let bg = h.state().session.active().unwrap().doc.layers[0].id; + h.state_mut().run("layer.select", json!({"layer": bg.0})).unwrap(); + } else { + h.query_by_label_contains("Hand Tool").expect("Hand Tool").click(); + } + h.run_steps(4); + let st = h.state().session.active().unwrap(); + assert_eq!(st.doc.layers.len(), 2, "via layer {via_layer}: the placed image stays"); + assert_eq!(red_bounds(&h), photocraft_geom::Rect::new(100, 100, 200, 160)); + } +} + +/// The placed fixture is a real place: cancelling the box takes the layer back, so the tests +/// above would see a switch that cancelled it. +#[test] +fn cancelling_the_placed_fixture_removes_its_layer() { + let mut h = placed_app(); + placed_and_moved(&mut h); + crate::transform_tool::cancel(h.state_mut()); + h.run_steps(2); + assert_eq!(h.state().session.active().unwrap().doc.layers.len(), 1); +} + +/// A new-layer command that can't run (Artboard from Group with a pixel layer active) leaves the box and the +/// placed layer as they were. +#[test] +fn a_new_layer_command_that_cant_run_leaves_the_box_open() { + let mut h = placed_app(); + placed_and_moved(&mut h); + let before = history(&h); + assert!(h.state_mut().run("layer.new.artboardFromGroup", json!({})).is_err()); + h.run_steps(2); + assert!(h.state().ui.transform.is_some(), "the box stays open"); + assert_eq!(h.state().session.active().unwrap().doc.layers.len(), 2, "the placed layer stays"); + assert_eq!(history(&h), before); +} + +/// A transform the engine refuses (a result far over the pixel budget, #3773) keeps the box open +/// with the placed layer, and the new layer isn't made: the refusal comes back. +#[test] +fn a_refused_transform_keeps_the_placed_layer_and_refuses_the_new_layer() { + let mut h = placed_app(); + placed_and_moved(&mut h); + let t = h.state_mut().ui.transform.as_mut().unwrap(); + t.quad = [[0.0, 0.0], [4.0e6, 0.0], [4.0e6, 4.0e6], [0.0, 4.0e6]]; + h.run_steps(2); + let before = history(&h); + let r = h.state_mut().run("layer.new.layer", json!({})); + h.run_steps(2); + assert!(r.is_err(), "{r:?}"); + assert!(h.state().ui.transform.is_some(), "the box stays open"); + assert_eq!(h.state().session.active().unwrap().doc.layers.len(), 2, "the placed layer stays, no new layer"); + assert_eq!(history(&h), before); +} + +/// New Layer with bad params, while a moved box is open, is refused before the transform is +/// applied: the box, the layer and History stay as they were. +#[test] +fn new_layer_with_bad_params_leaves_the_box_open() { + for bad in [json!({"mode": "Bogus"}), json!({"fillNeutral": true}), json!({"position": "x"}), json!([1])] { + let mut h = app(); + transform_and_move(&mut h); + let before = history(&h); + assert!(h.state_mut().run("layer.new.layer", bad.clone()).is_err(), "{bad}"); + h.run_steps(2); + assert!(h.state().ui.transform.is_some(), "{bad}: the box stays open"); + assert_eq!(history(&h), before, "{bad}: nothing applied"); + assert_eq!(red_bounds(&h), photocraft_geom::Rect::new(100, 100, 200, 160), "{bad}"); + } +} From 2b3d05636f1c8b2c3ad46ced89b5c40687e6caef Mon Sep 17 00:00:00 2001 From: Tanish Manku Date: Thu, 15 Oct 2026 15:32:08 -0700 Subject: [PATCH 37/47] Follow-ups to #5801/#5859: precheck docs name the group nesting limit; precheck test covers valid ids and null; Free Transform refusals translated; stranded-backup error tested (#6365) --- crates/engine/src/lib.rs | 10 ++++---- crates/engine/src/prefs/tests.rs | 37 +++++++++++++++++++++++++++++ crates/engine/tests/precheck.rs | 6 ++++- crates/ui-egui/src/i18n/cs.tsv | 3 +++ crates/ui-egui/src/i18n/de.tsv | 3 +++ crates/ui-egui/src/i18n/el.tsv | 3 +++ crates/ui-egui/src/i18n/es.tsv | 3 +++ crates/ui-egui/src/i18n/fr.tsv | 3 +++ crates/ui-egui/src/i18n/hu.tsv | 3 +++ crates/ui-egui/src/i18n/id.tsv | 3 +++ crates/ui-egui/src/i18n/it.tsv | 3 +++ crates/ui-egui/src/i18n/ja.tsv | 3 +++ crates/ui-egui/src/i18n/ko.tsv | 3 +++ crates/ui-egui/src/i18n/nl.tsv | 3 +++ crates/ui-egui/src/i18n/pl.tsv | 3 +++ crates/ui-egui/src/i18n/pt-br.tsv | 3 +++ crates/ui-egui/src/i18n/ru.tsv | 3 +++ crates/ui-egui/src/i18n/sr.tsv | 3 +++ crates/ui-egui/src/i18n/tr.tsv | 3 +++ crates/ui-egui/src/i18n/uk.tsv | 3 +++ crates/ui-egui/src/i18n/vi.tsv | 3 +++ crates/ui-egui/src/i18n/zh-hans.tsv | 3 +++ crates/ui-egui/src/i18n/zh-hant.tsv | 3 +++ crates/ui-egui/src/lib.rs | 4 ++-- 24 files changed, 110 insertions(+), 7 deletions(-) diff --git a/crates/engine/src/lib.rs b/crates/engine/src/lib.rs index e45826f34a..f13d61b979 100644 --- a/crates/engine/src/lib.rs +++ b/crates/engine/src/lib.rs @@ -422,8 +422,10 @@ pub struct CopiedStyle { pub mode: photocraft_color::ColorMode, } -/// The commands whose [`Session::precheck`] is complete: it accepts exactly what `execute` does -/// (Free Transform applies its box before these, and only when they will run). Layer via Copy and +/// The commands whose [`Session::precheck`] is complete: it accepts what `execute` does (Free +/// Transform applies its box before these, and only when they will run), but for the group +/// nesting limit ([`photocraft_doc::MAX_GROUP_DEPTH`]): grouping layers already that deep is +/// refused only as the edit is made. Layer via Copy and /// Cut aren't: whether they have anything to copy (a selection over transparent pixels, a group /// or fill layer) is known only when they run. pub const PRECHECKED: &[&str] = &["layer.new.layer", "layer.new.group", "layer.new.groupFromLayers", "layer.new.layerFromBackground"]; @@ -671,8 +673,8 @@ impl Session { /// something first only when the command will go through: Free Transform applies its box /// before a new layer is made. /// - /// Complete for the commands in [`PRECHECKED`]: there it accepts exactly what `execute` - /// accepts (a test holds the two together). For others it checks only what they share. + /// Complete for the commands in [`PRECHECKED`]: there it accepts what `execute` accepts, but + /// for the group nesting limit (a test holds the two together). For others it checks only what they share. pub fn precheck(&mut self, id: &str, params: &Value) -> Result<()> { let spec = crate::commands::find(id).ok_or_else(|| EngineError::UnknownCommand(id.to_string()))?; if !(params.is_object() || params.is_null()) { diff --git a/crates/engine/src/prefs/tests.rs b/crates/engine/src/prefs/tests.rs index 374131e85c..5314d1f197 100644 --- a/crates/engine/src/prefs/tests.rs +++ b/crates/engine/src/prefs/tests.rs @@ -878,3 +878,40 @@ fn exports_are_written_all_or_nothing() { assert_eq!(leftovers(), 1, "only c.kys.photocraft-tmp itself"); let _ = std::fs::remove_dir_all(&dir); } + +/// A backup that can't be put back after a failed export is named in the error, and stays where +/// it is with the user's previous text. +#[test] +fn a_stranded_backup_is_named_in_the_error() { + use super::write_files_atomically; + let dir = std::env::temp_dir().join(format!("photocraft-stranded-{}", std::process::id())); + std::fs::create_dir_all(&dir).unwrap(); + let (a, b) = (dir.join("a.kys"), dir.join("b.html")); + std::fs::write(&a, "old A").unwrap(); + std::fs::write(&b, "old B").unwrap(); + let (pa, pb) = (a.to_str().unwrap(), b.to_str().unwrap()); + let staged = std::cell::Cell::new(0); + // The second staged move fails, and so does putting a.kys's backup back. + let rename = |from: &std::path::Path, to: &std::path::Path| { + let f = from.to_string_lossy(); + if f.ends_with("photocraft-tmp") { + staged.set(staged.get() + 1); + if staged.get() == 2 { + return Err(std::io::Error::other("disk gone")); + } + } + if f.ends_with("photocraft-bak") && to == a.as_path() { + return Err(std::io::Error::other("locked")); + } + std::fs::rename(from, to) + }; + let e = write_files_atomically(&[(pa, "new A"), (pb, "new B")], &rename).unwrap_err(); + assert!(e.contains(&format!("the previous {} is at", a.display())), "{e}"); + let backup: Vec<_> = + std::fs::read_dir(&dir).unwrap().filter_map(|e| e.ok()).map(|e| e.path()).filter(|p| p.to_string_lossy().ends_with("photocraft-bak")).collect(); + assert_eq!(backup.len(), 1, "{backup:?}"); + assert!(e.contains(&backup[0].display().to_string()), "{e}"); + assert_eq!(std::fs::read_to_string(&backup[0]).unwrap(), "old A"); + assert_eq!(std::fs::read_to_string(&b).unwrap(), "old B"); + let _ = std::fs::remove_dir_all(&dir); +} diff --git a/crates/engine/tests/precheck.rs b/crates/engine/tests/precheck.rs index a28726dfeb..f549fbb8c8 100644 --- a/crates/engine/tests/precheck.rs +++ b/crates/engine/tests/precheck.rs @@ -1,4 +1,5 @@ -//! `Session::precheck` accepts exactly what `execute` accepts for the commands it covers +//! `Session::precheck` accepts what `execute` accepts for the commands it covers (all but the +//! group nesting limit, which only the edit checks) //! (`PRECHECKED`): Free Transform applies its box before them only when they will run (#5859). use photocraft_engine::{PRECHECKED, Session}; @@ -47,6 +48,9 @@ fn params() -> Vec { json!({"name": "ok"}), json!({"color": "chartreuse"}), json!({"layer": 999_999}), + json!({"layer": 1}), + json!({"layer": 2}), + json!({"layer": null}), json!({"layer": "Layer 1"}), json!({"layer": "no such layer"}), json!({"layer": true}), diff --git a/crates/ui-egui/src/i18n/cs.tsv b/crates/ui-egui/src/i18n/cs.tsv index 1329378dc2..c73c032df7 100644 --- a/crates/ui-egui/src/i18n/cs.tsv +++ b/crates/ui-egui/src/i18n/cs.tsv @@ -3805,3 +3805,6 @@ navigator Zoom Zvětšení Undo Changes Vrátit změny {shortcut} is reserved by the system and can't be assigned. {shortcut} je vyhrazena systémem a nelze ji přiřadit. Low-Latency Drawing (vsync off, applies at next launch) Kreslení s nízkou latencí (bez vertikální synchronizace, platí po dalším spuštění) +# Free Transform: a new layer while the box is open (#5859) + Free Transform is still being applied; try again when it has finished. Volná transformace se stále použije; zkuste to znovu, až skončí. + The transform couldn't be applied. Transformaci nelze použít. diff --git a/crates/ui-egui/src/i18n/de.tsv b/crates/ui-egui/src/i18n/de.tsv index 5f56421233..bfa6d5f8f1 100644 --- a/crates/ui-egui/src/i18n/de.tsv +++ b/crates/ui-egui/src/i18n/de.tsv @@ -3199,3 +3199,6 @@ curves Smooth Glätten Undo Changes Änderungen rückgängig machen {shortcut} is reserved by the system and can't be assigned. {shortcut} ist vom System reserviert und kann nicht zugewiesen werden. Low-Latency Drawing (vsync off, applies at next launch) Zeichnen mit geringer Latenz (ohne VSync, gilt ab dem nächsten Start) +# Free Transform: a new layer while the box is open (#5859) + Free Transform is still being applied; try again when it has finished. Frei transformieren wird noch angewendet; versuchen Sie es erneut, wenn es fertig ist. + The transform couldn't be applied. Die Transformation konnte nicht angewendet werden. diff --git a/crates/ui-egui/src/i18n/el.tsv b/crates/ui-egui/src/i18n/el.tsv index 715e41cb1f..eccfd0508c 100644 --- a/crates/ui-egui/src/i18n/el.tsv +++ b/crates/ui-egui/src/i18n/el.tsv @@ -3897,3 +3897,6 @@ curves Smooth Εξομάλυνση Undo Changes Αναίρεση αλλαγών {shortcut} is reserved by the system and can't be assigned. Η συντόμευση {shortcut} δεσμεύεται από το σύστημα και δεν μπορεί να εκχωρηθεί. Low-Latency Drawing (vsync off, applies at next launch) Σχεδίαση χαμηλής καθυστέρησης (χωρίς vsync, ισχύει στην επόμενη εκκίνηση) +# Free Transform: a new layer while the box is open (#5859) + Free Transform is still being applied; try again when it has finished. Ο Ελεύθερος μετασχηματισμός εφαρμόζεται ακόμη· δοκιμάστε ξανά όταν ολοκληρωθεί. + The transform couldn't be applied. Δεν ήταν δυνατή η εφαρμογή του μετασχηματισμού. diff --git a/crates/ui-egui/src/i18n/es.tsv b/crates/ui-egui/src/i18n/es.tsv index 4a0f1c8556..da9337eaea 100644 --- a/crates/ui-egui/src/i18n/es.tsv +++ b/crates/ui-egui/src/i18n/es.tsv @@ -4159,3 +4159,6 @@ navigator Zoom Zoom Undo Changes Deshacer cambios {shortcut} is reserved by the system and can't be assigned. {shortcut} está reservado por el sistema y no se puede asignar. Low-Latency Drawing (vsync off, applies at next launch) Dibujo de baja latencia (sin sincronización vertical, se aplica al volver a iniciar) +# Free Transform: a new layer while the box is open (#5859) + Free Transform is still being applied; try again when it has finished. Transformación libre aún se está aplicando; inténtelo de nuevo cuando termine. + The transform couldn't be applied. No se pudo aplicar la transformación. diff --git a/crates/ui-egui/src/i18n/fr.tsv b/crates/ui-egui/src/i18n/fr.tsv index 2402aec160..8b25af722e 100644 --- a/crates/ui-egui/src/i18n/fr.tsv +++ b/crates/ui-egui/src/i18n/fr.tsv @@ -3202,3 +3202,6 @@ curves Smooth Lisser Undo Changes Annuler les modifications {shortcut} is reserved by the system and can't be assigned. {shortcut} est réservé par le système et ne peut pas être attribué. Low-Latency Drawing (vsync off, applies at next launch) Dessin à faible latence (sans synchronisation verticale, au prochain lancement) +# Free Transform: a new layer while the box is open (#5859) + Free Transform is still being applied; try again when it has finished. La transformation manuelle est encore en cours d'application ; réessayez quand elle sera terminée. + The transform couldn't be applied. Impossible d'appliquer la transformation. diff --git a/crates/ui-egui/src/i18n/hu.tsv b/crates/ui-egui/src/i18n/hu.tsv index 343dcadc0c..2870dc6f99 100644 --- a/crates/ui-egui/src/i18n/hu.tsv +++ b/crates/ui-egui/src/i18n/hu.tsv @@ -4116,3 +4116,6 @@ cameraRaw Yellow Sárga Undo Changes Változások visszavonása {shortcut} is reserved by the system and can't be assigned. A(z) {shortcut} a rendszer számára fenntartott, nem rendelhető hozzá. Low-Latency Drawing (vsync off, applies at next launch) Kis késleltetésű rajzolás (vsync nélkül, a következő indításkor lép életbe) +# Free Transform: a new layer while the box is open (#5859) + Free Transform is still being applied; try again when it has finished. A szabad alakítás alkalmazása még folyamatban van; próbáld újra, ha befejeződött. + The transform couldn't be applied. Az átalakítás nem alkalmazható. diff --git a/crates/ui-egui/src/i18n/id.tsv b/crates/ui-egui/src/i18n/id.tsv index e4ba61e1a1..feca83a9c7 100644 --- a/crates/ui-egui/src/i18n/id.tsv +++ b/crates/ui-egui/src/i18n/id.tsv @@ -3189,3 +3189,6 @@ curves Smooth Haluskan Undo Changes Batalkan Perubahan {shortcut} is reserved by the system and can't be assigned. {shortcut} dicadangkan oleh sistem dan tidak dapat ditetapkan. Low-Latency Drawing (vsync off, applies at next launch) Menggambar latensi rendah (tanpa vsync, berlaku saat peluncuran berikutnya) +# Free Transform: a new layer while the box is open (#5859) + Free Transform is still being applied; try again when it has finished. Transformasi Bebas masih diterapkan; coba lagi setelah selesai. + The transform couldn't be applied. Transformasi tidak dapat diterapkan. diff --git a/crates/ui-egui/src/i18n/it.tsv b/crates/ui-egui/src/i18n/it.tsv index 6b933287c6..a8796e64e7 100644 --- a/crates/ui-egui/src/i18n/it.tsv +++ b/crates/ui-egui/src/i18n/it.tsv @@ -4318,3 +4318,6 @@ shape Appearance Aspetto Undo Changes Annulla modifiche {shortcut} is reserved by the system and can't be assigned. {shortcut} è riservata dal sistema e non può essere assegnata. Low-Latency Drawing (vsync off, applies at next launch) Disegno a bassa latenza (senza vsync, si applica al prossimo avvio) +# Free Transform: a new layer while the box is open (#5859) + Free Transform is still being applied; try again when it has finished. Trasformazione libera è ancora in corso di applicazione; riprova quando è terminata. + The transform couldn't be applied. Impossibile applicare la trasformazione. diff --git a/crates/ui-egui/src/i18n/ja.tsv b/crates/ui-egui/src/i18n/ja.tsv index 2f6b7d7f5e..13efa7517d 100644 --- a/crates/ui-egui/src/i18n/ja.tsv +++ b/crates/ui-egui/src/i18n/ja.tsv @@ -3873,3 +3873,6 @@ curves Smooth スムーズ Undo Changes 変更を取り消し {shortcut} is reserved by the system and can't be assigned. {shortcut} はシステムで予約されているため割り当てられません。 Low-Latency Drawing (vsync off, applies at next launch) 低遅延描画(垂直同期オフ、次回起動時に適用) +# Free Transform: a new layer while the box is open (#5859) + Free Transform is still being applied; try again when it has finished. 自由変形を適用中です。完了してからもう一度お試しください。 + The transform couldn't be applied. 変形を適用できませんでした。 diff --git a/crates/ui-egui/src/i18n/ko.tsv b/crates/ui-egui/src/i18n/ko.tsv index 3c056c627b..5354b04a31 100644 --- a/crates/ui-egui/src/i18n/ko.tsv +++ b/crates/ui-egui/src/i18n/ko.tsv @@ -3375,3 +3375,6 @@ curves Smooth 매끄럽게 Undo Changes 변경 사항 취소 {shortcut} is reserved by the system and can't be assigned. {shortcut}은(는) 시스템에서 예약되어 할당할 수 없습니다. Low-Latency Drawing (vsync off, applies at next launch) 저지연 그리기(수직 동기화 끔, 다음 실행 시 적용) +# Free Transform: a new layer while the box is open (#5859) + Free Transform is still being applied; try again when it has finished. 자유 변형을 아직 적용하는 중입니다. 완료된 후 다시 시도하세요. + The transform couldn't be applied. 변형을 적용할 수 없습니다. diff --git a/crates/ui-egui/src/i18n/nl.tsv b/crates/ui-egui/src/i18n/nl.tsv index c80f86bf81..5a013c7862 100644 --- a/crates/ui-egui/src/i18n/nl.tsv +++ b/crates/ui-egui/src/i18n/nl.tsv @@ -3957,3 +3957,6 @@ curves Smooth Vloeiend maken Undo Changes Wijzigingen ongedaan maken {shortcut} is reserved by the system and can't be assigned. {shortcut} is door het systeem gereserveerd en kan niet worden toegewezen. Low-Latency Drawing (vsync off, applies at next launch) Tekenen met lage latentie (zonder vsync, na de volgende start) +# Free Transform: a new layer while the box is open (#5859) + Free Transform is still being applied; try again when it has finished. Vrije transformatie wordt nog toegepast; probeer het opnieuw als die klaar is. + The transform couldn't be applied. De transformatie kon niet worden toegepast. diff --git a/crates/ui-egui/src/i18n/pl.tsv b/crates/ui-egui/src/i18n/pl.tsv index 85df6b8157..8707b5fb2e 100644 --- a/crates/ui-egui/src/i18n/pl.tsv +++ b/crates/ui-egui/src/i18n/pl.tsv @@ -3795,3 +3795,6 @@ curves Smooth Wygładź Undo Changes Cofnij zmiany {shortcut} is reserved by the system and can't be assigned. {shortcut} jest zarezerwowany przez system i nie można go przypisać. Low-Latency Drawing (vsync off, applies at next launch) Rysowanie z małym opóźnieniem (bez synchronizacji pionowej, od następnego uruchomienia) +# Free Transform: a new layer while the box is open (#5859) + Free Transform is still being applied; try again when it has finished. Przekształcanie swobodne jest nadal stosowane; spróbuj ponownie, gdy się zakończy. + The transform couldn't be applied. Nie można zastosować przekształcenia. diff --git a/crates/ui-egui/src/i18n/pt-br.tsv b/crates/ui-egui/src/i18n/pt-br.tsv index 211442ba20..ef10787ceb 100644 --- a/crates/ui-egui/src/i18n/pt-br.tsv +++ b/crates/ui-egui/src/i18n/pt-br.tsv @@ -3987,3 +3987,6 @@ layerLabel No Color Sem cor Undo Changes Desfazer alterações {shortcut} is reserved by the system and can't be assigned. {shortcut} é reservado pelo sistema e não pode ser atribuído. Low-Latency Drawing (vsync off, applies at next launch) Desenho de baixa latência (sem sincronização vertical, aplicado na próxima inicialização) +# Free Transform: a new layer while the box is open (#5859) + Free Transform is still being applied; try again when it has finished. A Transformação livre ainda está sendo aplicada; tente novamente quando terminar. + The transform couldn't be applied. Não foi possível aplicar a transformação. diff --git a/crates/ui-egui/src/i18n/ru.tsv b/crates/ui-egui/src/i18n/ru.tsv index 9dfcc85093..449f741e21 100644 --- a/crates/ui-egui/src/i18n/ru.tsv +++ b/crates/ui-egui/src/i18n/ru.tsv @@ -3167,3 +3167,6 @@ curves Smooth Сгладить Undo Changes Отменить изменения {shortcut} is reserved by the system and can't be assigned. Комбинация {shortcut} зарезервирована системой и не может быть назначена. Low-Latency Drawing (vsync off, applies at next launch) Рисование с малой задержкой (без вертикальной синхронизации, после перезапуска) +# Free Transform: a new layer while the box is open (#5859) + Free Transform is still being applied; try again when it has finished. Свободное трансформирование ещё применяется; повторите попытку, когда оно завершится. + The transform couldn't be applied. Не удалось применить трансформирование. diff --git a/crates/ui-egui/src/i18n/sr.tsv b/crates/ui-egui/src/i18n/sr.tsv index bbc0ef528d..4dedfaf7a1 100644 --- a/crates/ui-egui/src/i18n/sr.tsv +++ b/crates/ui-egui/src/i18n/sr.tsv @@ -4270,3 +4270,6 @@ shape Appearance Izgled Undo Changes Poništi izmene {shortcut} is reserved by the system and can't be assigned. {shortcut} je rezervisana od strane sistema i ne može se dodeliti. Low-Latency Drawing (vsync off, applies at next launch) Crtanje sa malim kašnjenjem (bez vsync-a, primenjuje se pri sledećem pokretanju) +# Free Transform: a new layer while the box is open (#5859) + Free Transform is still being applied; try again when it has finished. Slobodna transformacija se još primenjuje; pokušajte ponovo kad se završi. + The transform couldn't be applied. Transformacija nije mogla da se primeni. diff --git a/crates/ui-egui/src/i18n/tr.tsv b/crates/ui-egui/src/i18n/tr.tsv index b63bdd7349..f7dc6961fa 100644 --- a/crates/ui-egui/src/i18n/tr.tsv +++ b/crates/ui-egui/src/i18n/tr.tsv @@ -3927,3 +3927,6 @@ cameraData Lens Lens Undo Changes Değişiklikleri Geri Al {shortcut} is reserved by the system and can't be assigned. {shortcut} sistem tarafından ayrılmıştır ve atanamaz. Low-Latency Drawing (vsync off, applies at next launch) Düşük gecikmeli çizim (dikey eşitleme kapalı, sonraki açılışta geçerli) +# Free Transform: a new layer while the box is open (#5859) + Free Transform is still being applied; try again when it has finished. Serbest Dönüştürme hâlâ uygulanıyor; bittiğinde yeniden deneyin. + The transform couldn't be applied. Dönüştürme uygulanamadı. diff --git a/crates/ui-egui/src/i18n/uk.tsv b/crates/ui-egui/src/i18n/uk.tsv index 923ec694af..9a018c744a 100644 --- a/crates/ui-egui/src/i18n/uk.tsv +++ b/crates/ui-egui/src/i18n/uk.tsv @@ -3896,3 +3896,6 @@ curves Smooth Згладити Undo Changes Скасувати зміни {shortcut} is reserved by the system and can't be assigned. {shortcut} зарезервовано системою, його не можна призначити. Low-Latency Drawing (vsync off, applies at next launch) Малювання з малою затримкою (без вертикальної синхронізації, після перезапуску) +# Free Transform: a new layer while the box is open (#5859) + Free Transform is still being applied; try again when it has finished. Вільне трансформування ще застосовується; спробуйте знову, коли воно завершиться. + The transform couldn't be applied. Не вдалося застосувати трансформування. diff --git a/crates/ui-egui/src/i18n/vi.tsv b/crates/ui-egui/src/i18n/vi.tsv index be40230ca3..b798849d72 100644 --- a/crates/ui-egui/src/i18n/vi.tsv +++ b/crates/ui-egui/src/i18n/vi.tsv @@ -3683,3 +3683,6 @@ curves Smooth Làm mượt Undo Changes Hoàn tác thay đổi {shortcut} is reserved by the system and can't be assigned. {shortcut} được hệ thống dành riêng và không thể gán. Low-Latency Drawing (vsync off, applies at next launch) Vẽ độ trễ thấp (tắt vsync, áp dụng ở lần khởi động sau) +# Free Transform: a new layer while the box is open (#5859) + Free Transform is still being applied; try again when it has finished. Biến đổi tự do vẫn đang được áp dụng; hãy thử lại khi hoàn tất. + The transform couldn't be applied. Không thể áp dụng phép biến đổi. diff --git a/crates/ui-egui/src/i18n/zh-hans.tsv b/crates/ui-egui/src/i18n/zh-hans.tsv index 7c563780db..766497d67b 100644 --- a/crates/ui-egui/src/i18n/zh-hans.tsv +++ b/crates/ui-egui/src/i18n/zh-hans.tsv @@ -2813,3 +2813,6 @@ curves Smooth 平滑 Undo Changes 撤消更改 {shortcut} is reserved by the system and can't be assigned. {shortcut} 由系统保留,无法指定。 Low-Latency Drawing (vsync off, applies at next launch) 低延迟绘画(关闭垂直同步,下次启动时生效) +# Free Transform: a new layer while the box is open (#5859) + Free Transform is still being applied; try again when it has finished. 自由变换仍在应用中;请在完成后重试。 + The transform couldn't be applied. 无法应用变换。 diff --git a/crates/ui-egui/src/i18n/zh-hant.tsv b/crates/ui-egui/src/i18n/zh-hant.tsv index 9376cb05bb..3627c785e8 100644 --- a/crates/ui-egui/src/i18n/zh-hant.tsv +++ b/crates/ui-egui/src/i18n/zh-hant.tsv @@ -2798,3 +2798,6 @@ curves Smooth 平滑 Undo Changes 還原變更 {shortcut} is reserved by the system and can't be assigned. {shortcut} 由系統保留,無法指定。 Low-Latency Drawing (vsync off, applies at next launch) 低延遲繪圖(關閉垂直同步,下次啟動時生效) +# Free Transform: a new layer while the box is open (#5859) + Free Transform is still being applied; try again when it has finished. 任意變形仍在套用中;請在完成後再試一次。 + The transform couldn't be applied. 無法套用變形。 diff --git a/crates/ui-egui/src/lib.rs b/crates/ui-egui/src/lib.rs index 27a2004012..bde51ff27a 100644 --- a/crates/ui-egui/src/lib.rs +++ b/crates/ui-egui/src/lib.rs @@ -1061,10 +1061,10 @@ impl PhotocraftApp { self.session.precheck(id, ¶ms).map_err(|e| e.to_string())?; transform_tool::commit(self); if transform_tool::committing(self).is_some() { - return Err("the transform is still being applied; try again when it has finished".into()); + return Err(tl!("Free Transform is still being applied; try again when it has finished.").into()); } if self.ui.transform.is_some() { - return Err(if self.ui.status.is_empty() { "the transform couldn't be applied".into() } else { self.ui.status.clone() }); + return Err(if self.ui.status.is_empty() { tl!("The transform couldn't be applied.").into() } else { self.ui.status.clone() }); } } if let Some(result) = panel_docking::command(self, id, ¶ms) { From b630d7cb9aeb2a79ab5224ebf6e8266f1d858873 Mon Sep 17 00:00:00 2001 From: skylar taylor-barrick Date: Thu, 15 Oct 2026 18:19:26 -0600 Subject: [PATCH 38/47] Security: enforce the automation read boundary on stored smart-filter params (#5684) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * Security: enforce the automation read boundary on stored smart-filter params Smart filters store a command id and params inside the document, so a crafted .pcraft could resolve stored ambient paths (displace mapPath, color-lookup LUT files) at render time without the authorize gate: synchronous smart-object rendering has no Session argument, spawned job threads drop the requesting session's authority, and a trusted UI render could warm the process-wide stack cache with a stage the gate never saw. Thread the requesting session's gate through a thread-local in jobs.rs (entered around command execution, reinstalled on spawned workers and on job apply, restored on drop), re-check stored filter commands in render_in before source resolution and any cache lookup and in apply_filter_to_surface, and re-authorize layer.smartFilter.setParam with the complete merged params after merging stored document params — including edits whose request contains no path and actions.play steps. Denial is conservative: the document is unchanged and no worker starts. Regression tests in the automation crate cover rerender, cached-stage reuse, merged-param edits, action playback, background jobs, and filter transfer, for both the displace map and color-lookup LUT vectors. * Review: carry render authority through Session::edit and surface denials Session::edit is the engine's universal mutation entry (linked-file refresh, transforms, place); renders reached through it ran with no authorization scope installed, so stored smart-filter params read ambiently there even after the execute-path fix. Install the scope in Session::edit as well, keyed on the session's authorize gate. apply_filter_to_surface also conflated a denied filter with an unknown one: authorize_render_command's Result was discarded with .ok()?, so a denial silently rendered unfiltered pixels and the stage entered the process-wide stack cache. The signature becomes Result>: Err for denials (callers propagate), None for ids that aren't pixel filters (pass-through semantics unchanged), and an unreadable map keeps its deliberate pass-through. apply_smart_filter and apply_smart_filters propagate instead of cur.clone(). Two tests: a refresh driven outside command execution (Session::edit) is denied and leaves the document unchanged; a denied render caches no stage. Nine authorization tests pass, engine suite and strict Clippy clean, panic hunt green. * ui_set description: name the layerFilter field main added The Layers-panel filter dropdown (#5048) added layerFilter to the control method's UI_SET_FIELDS without extending the ui_set tool's description, so the drift guard (mcp.rs, ui_set_description_names_every_ accepted_field) fails on Windows CI for any branch built on main. Name the field with its patch shape: by (kind, name or mode), kinds (a list of layer kinds), name (matches part of the layer name), mode (a blend mode name). The 40 MCP tests pass; strict Clippy clean. * wasm size: compose and algo join cms at opt-level "z" to clear the 24 MiB gate Main's browser build is 171 KB over the 24 MiB wasm gate in CI (25,337,343 bytes), so every branch built on current main fails the web size check. Demote three pixel crates in the wasm-release profile: - cms: transforms are built once and cached (transform::cached), so "z" costs little runtime (measured -42 KB). - compose: the browser app composites per frame on the GPU (crates/gpu, WebGPU/WebGL2), so the CPU compositor is not the browser's hot path there (measured -122 KB). - algo: filters run per pixel on every interactive filter application. "z" there is the lever of last resort (measured -331 KB), taken because the gate is enforced in CI; if browser filters feel too slow, restore 3 here and reclaim the bytes from a feature-gated decoder. Final size 24,811,949 bytes locally, 354 KB under the gate; the native profiles are untouched. If a maintainer prefers keeping algo at 3, the alternative is raising the self-imposed 24 MiB early-warning limit (Cloudflare's hard cap is 25 MiB) - a policy call in packaging/web/ package.sh, not a code fix. --- Cargo.toml | 15 +- crates/automation/src/server.rs | 9 +- .../tests/smart_filter_authorization.rs | 261 ++++++++++++++++++ crates/engine/src/filters.rs | 26 +- crates/engine/src/jobs.rs | 42 +++ crates/engine/src/lib.rs | 3 + crates/engine/src/smart_cmds.rs | 44 ++- .../src/smart_cmds/tests/stack_cache.rs | 2 +- 8 files changed, 370 insertions(+), 32 deletions(-) create mode 100644 crates/automation/tests/smart_filter_authorization.rs diff --git a/Cargo.toml b/Cargo.toml index 663d268c3a..3c5ad2e0ff 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -127,17 +127,24 @@ opt-level = "z" [profile.wasm-release.package.naga] opt-level = "z" -# Pixel processing keeps its speed-oriented optimization level. +# Pixel processing keeps its speed-oriented optimization level, with two exceptions. +# Colour-management transforms are built once and cached (`transform::cached`), so "z" costs +# little runtime. The browser app composites per frame on the GPU (`crates/gpu`, WebGPU/WebGL2), +# so the CPU compositor is not the browser's hot path there; "z" for compose keeps the wasm +# under the 24 MiB gate (measured −122 KB vs opt-level 3). algo's filters run per pixel on +# every interactive filter application; "z" there is the size lever of last resort (−331 KB), +# taken because the gate is enforced in CI: if browser filters feel too slow, restore 3 here +# and reclaim the bytes from a feature-gated decoder instead. [profile.wasm-release.package.photocraft-raster] opt-level = 3 [profile.wasm-release.package.photocraft-color] opt-level = 3 [profile.wasm-release.package.photocraft-cms] -opt-level = 3 +opt-level = "z" [profile.wasm-release.package.photocraft-compose] -opt-level = 3 +opt-level = "z" [profile.wasm-release.package.photocraft-algo] -opt-level = 3 +opt-level = "z" [profile.wasm-release.package.photocraft-paint] opt-level = 3 diff --git a/crates/automation/src/server.rs b/crates/automation/src/server.rs index f223bc5109..f52376a9c5 100644 --- a/crates/automation/src/server.rs +++ b/crates/automation/src/server.rs @@ -325,10 +325,11 @@ pub struct UiSetParams { /// dashes, dashOffset and opacity; null disables the stroke). Shape stroke fields use /// `shape.edit` names and units; colour comes from `tools.setColors`. camTransformOnDrop (bool, /// the Content-Aware Move tool's Transform On Drop). tileGrid (bool, a developer overlay of the - /// active layer's storage tiles). layerFilter (an object patching the Layers panel filter: - /// by: kind/name/mode; kinds: a list of pixel/adjustment/type/shape/smart; name: a string; - /// mode: a blend mode name such as Multiply or Pass Through). Omitted filter fields keep - /// their current values. Other fields are an error. + /// active layer's storage tiles). layerFilter (an object patching by ("kind", "name" or + /// "mode"), kinds (a list of layer kinds), name (matches part of the layer name) and mode (a + /// blend mode name) for the Layers panel filter). strokeTrace (true, false, "stderr" or "off": + /// the stroke latency trace at runtime; a CSV file is set at launch with --stroke-trace, not + /// over this channel). Other fields are an error. pub fields: Value, } diff --git a/crates/automation/tests/smart_filter_authorization.rs b/crates/automation/tests/smart_filter_authorization.rs new file mode 100644 index 0000000000..a5c1681a52 --- /dev/null +++ b/crates/automation/tests/smart_filter_authorization.rs @@ -0,0 +1,261 @@ +//! Stored smart-filter parameters must not grant automation ambient filesystem access. +use std::path::{Path, PathBuf}; +use std::sync::{ + Arc, + atomic::{AtomicU64, Ordering}, +}; + +use photocraft_automation::{AuthorizedWorkspace, Headless, workspace::authorize_desktop_engine_step}; +use photocraft_doc::{Document, LayerContent, LayerId}; +use photocraft_engine::{Session, actions_cmds::Action, jobs::Started, smart_cmds}; +use serde_json::{Value, json}; + +struct Fixture(PathBuf); + +impl Fixture { + fn new() -> Self { + static NEXT: AtomicU64 = AtomicU64::new(0); + let stamp = std::time::SystemTime::now().duration_since(std::time::UNIX_EPOCH).unwrap().as_nanos(); + let base = std::env::temp_dir().join(format!("pc-filter-auth-{}-{stamp}-{}", std::process::id(), NEXT.fetch_add(1, Ordering::Relaxed))); + std::fs::create_dir(&base).unwrap(); + std::fs::create_dir(base.join("workspace")).unwrap(); + std::fs::create_dir(base.join("outside")).unwrap(); + let f = Self(base); + std::fs::write(f.root().join("stripes.png"), png(|x| if (x / 2) % 2 == 0 { 0 } else { 255 })).unwrap(); + std::fs::write(f.outside("black.png"), png(|_| 0)).unwrap(); + std::fs::write(f.outside("white.png"), png(|_| 255)).unwrap(); + std::fs::write(f.outside("black.cube"), cube(0)).unwrap(); + std::fs::write(f.outside("white.cube"), cube(1)).unwrap(); + f + } + + fn root(&self) -> PathBuf { + self.0.join("workspace") + } + fn outside(&self, name: &str) -> PathBuf { + self.0.join("outside").join(name) + } + fn headless(&self) -> Headless { + Headless::with_workspace(AuthorizedWorkspace::new(Some(&self.root()), Some(&self.root())).unwrap()) + } + fn setup(&self, lookup: bool) -> (Headless, LayerId) { + let mut h = self.headless(); + h.handle("doc.new", json!({"width": 32, "height": 32, "background": "#808080"})).unwrap(); + h.handle("doc.new", json!({"width": 32, "height": 32})).unwrap(); + let layer = LayerId(h.handle("doc.place", json!({"path": "stripes.png"})).unwrap()["layer"].as_u64().unwrap()); + let (id, params) = if lookup { + ("image.adjustments.colorLookup", json!({"layer": layer.0, "lut": "none"})) + } else { + ("filter.distort.displace", json!({"layer": layer.0, "mapDocument": 0, "horizontal": 50, "vertical": 0})) + }; + h.session.execute(id, params).unwrap(); + (h, layer) + } + fn saved_filter(&self, lookup: bool) -> (Document, LayerId) { + let (h, layer) = self.setup(lookup); + let mut doc = (*h.session.active().unwrap().doc).clone(); + let LayerContent::Smart(sm) = &mut doc.layer_mut(layer).unwrap().content else { panic!("smart object") }; + sm.smart_filters[0].params = path_params(lookup, &self.outside(if lookup { "white.cube" } else { "white.png" })); + let bytes = photocraft_format::save_to_bytes(&doc, &Default::default()).unwrap(); + std::fs::write(self.root().join("stored.pcraft"), bytes).unwrap(); + (doc, layer) + } +} +impl Drop for Fixture { + fn drop(&mut self) { + let _ = std::fs::remove_dir_all(&self.0); + } +} + +fn png(value: impl Fn(u32) -> u8) -> Vec { + let mut pixels = Vec::new(); + for _ in 0..32 { + for x in 0..32 { + pixels.extend([value(x); 3]); + } + } + let image = photocraft_codecs::Image::from_u8(32, 32, photocraft_codecs::ChannelLayout::Rgb, pixels).unwrap(); + photocraft_codecs::encode(&image, photocraft_codecs::Format::Png, &Default::default()).unwrap() +} +fn cube(value: u8) -> String { + format!("LUT_3D_SIZE 2\n{}", format!("{value} {value} {value}\n").repeat(8)) +} +fn path_params(lookup: bool, path: &Path) -> Value { + if lookup { json!({"file": path}) } else { json!({"mapPath": path, "mapDocument": null}) } +} +fn assert_denied(result: Result) { + match result { + Err(error) => assert!(error.to_string().contains("ambient filesystem"), "{error}"), + Ok(value) => panic!("ambient file access was accepted: {value:?}"), + } +} + +#[test] +fn parameter_edits_reject_outside_images_and_luts_without_changing_the_document() { + for lookup in [false, true] { + let f = Fixture::new(); + let (mut h, layer) = f.setup(lookup); + let before = h.render_png(None, 64).unwrap(); + let doc = h.session.active().unwrap().doc.clone(); + let journal = h.session.journal.len(); + for name in if lookup { ["black.cube", "white.cube"] } else { ["black.png", "white.png"] } { + let params = path_params(lookup, &f.outside(name)); + let id = if lookup { "image.adjustments.colorLookup" } else { "filter.distort.displace" }; + assert_denied(h.session.execute(id, params.clone())); + assert_denied(h.handle("engine.execute", json!({"command": "layer.smartFilter.setParams", "params": {"layer": layer.0, "params": params}}))); + assert!(Arc::ptr_eq(&doc, &h.session.active().unwrap().doc)); + assert_eq!(h.session.journal.len(), journal); + assert_eq!(h.render_png(None, 64).unwrap(), before); + } + // Safe numeric and embedded-data edits remain usable. + let safe = if lookup { json!({"data": cube(0), "fileName": "embedded.cube"}) } else { json!({"horizontal": 10}) }; + h.session.execute("layer.smartFilter.setParams", json!({"layer": layer.0, "params": safe})).unwrap(); + } +} + +#[test] +fn action_playback_cannot_hide_a_smart_filter_path_edit() { + let f = Fixture::new(); + let (mut h, layer) = f.setup(false); + let doc = h.session.active().unwrap().doc.clone(); + h.session.actions.list.push(Action::new( + "edit filter", + vec![("layer.smartFilter.setParams".into(), json!({"layer": layer.0, "params": path_params(false, &f.outside("white.png"))}))], + )); + let result = h.session.execute("actions.play", json!({"action": "edit filter"})).unwrap(); + assert!(result["failed"]["error"].as_str().unwrap().contains("ambient filesystem"), "{result}"); + assert!(Arc::ptr_eq(&doc, &h.session.active().unwrap().doc)); +} + +#[test] +fn loaded_filters_are_checked_after_merging_even_when_the_edit_contains_no_path() { + for lookup in [false, true] { + let f = Fixture::new(); + let (_, layer) = f.saved_filter(lookup); + let mut h = f.headless(); + h.open(Path::new("stored.pcraft")).unwrap(); + let doc = h.session.active().unwrap().doc.clone(); + assert_denied(h.session.execute("layer.smartFilter.setParams", json!({"layer": layer.0, "params": {"horizontal": 10, "dither": false}}))); + assert!(Arc::ptr_eq(&doc, &h.session.active().unwrap().doc)); + // Removing an ambient input makes the filter usable again. + let safe = if lookup { json!({"file": "", "lut": "none"}) } else { json!({"mapPath": ""}) }; + h.session.execute("layer.smartFilter.setParams", json!({"layer": layer.0, "params": safe})).unwrap(); + } +} + +#[test] +fn loaded_filters_cannot_read_on_rerender_or_reuse_a_trusted_cached_stage() { + for lookup in [false, true] { + let f = Fixture::new(); + let (doc, layer) = f.saved_filter(lookup); + for warm_cache in [false, true] { + if warm_cache { + let mut trusted = doc.clone(); + assert!(smart_cmds::refresh(&mut trusted, layer).unwrap()); + } + let mut h = f.headless(); + h.open(Path::new("stored.pcraft")).unwrap(); + let before = h.session.active().unwrap().doc.clone(); + assert_denied(h.session.execute("layer.smartFilter.setVisible", json!({"layer": layer.0, "visible": true}))); + assert!(Arc::ptr_eq(&before, &h.session.active().unwrap().doc)); + } + } +} + +#[test] +fn background_rerender_keeps_the_desktop_request_authority() { + for lookup in [false, true] { + let f = Fixture::new(); + let (doc, layer) = f.saved_filter(lookup); + let mut s = Session::new(); + s.add_document(doc, None); + s.authorize = Some(authorize_desktop_engine_step); + let before = s.active().unwrap().doc.clone(); + let started = s.start("layer.smartFilter.setVisible", json!({"layer": layer.0, "visible": true})).unwrap(); + // The desktop restores UI authority as soon as the automation request returns. + s.authorize = None; + let Started::Job(job) = started else { panic!("expected a background render") }; + assert_denied(s.wait_job(job)); + assert!(Arc::ptr_eq(&before, &s.active().unwrap().doc)); + // The worker's restriction must not leak into subsequent trusted UI work. + s.execute("layer.smartFilter.setVisible", json!({"layer": layer.0, "visible": true})).unwrap(); + } +} + +#[test] +fn transferring_stored_filters_cannot_read_outside_files() { + for lookup in [false, true] { + let f = Fixture::new(); + let (_, from) = f.saved_filter(lookup); + let mut h = f.headless(); + h.open(Path::new("stored.pcraft")).unwrap(); + let to = h.handle("doc.place", json!({"path": "stripes.png"})).unwrap()["layer"].as_u64().unwrap(); + let before = h.session.active().unwrap().doc.clone(); + for copy in [false, true] { + assert_denied(h.session.execute("layer.smartFilter.transfer", json!({"from": from.0, "to": to, "copy": copy}))); + assert!(Arc::ptr_eq(&before, &h.session.active().unwrap().doc)); + } + } +} + +#[test] +fn trusted_parameter_edits_still_read_maps_and_luts_after_a_denied_request() { + for lookup in [false, true] { + let f = Fixture::new(); + let (mut h, layer) = f.setup(lookup); + let edit = |path: &Path| json!({"layer": layer.0, "params": path_params(lookup, path)}); + assert_denied(h.session.execute("layer.smartFilter.setParams", edit(&f.outside("white.png")))); + let mut trusted = Headless::trusted_local(); + trusted.session.add_document((*h.session.active().unwrap().doc).clone(), None); + let mut renders = Vec::new(); + for name in if lookup { ["black.cube", "white.cube", "black.cube"] } else { ["black.png", "white.png", "black.png"] } { + trusted.session.execute("layer.smartFilter.setParams", edit(&f.outside(name))).unwrap(); + renders.push(trusted.render_png(None, 64).unwrap()); + } + assert_ne!(renders[0], renders[1], "trusted path-based filter still works"); + assert_eq!(renders[0], renders[2], "trusted rendering remains deterministic"); + } +} + +#[test] +fn a_refresh_outside_command_execution_surfaces_the_denial_instead_of_rendering_unfiltered() { + // link-watch and transform paths re-render through Session::edit, not Session::execute or + // jobs::run; the gate must reach them and the denial must not silently pass pixels through + // into the process-wide stack cache. + for lookup in [false, true] { + let f = Fixture::new(); + let (doc, layer) = f.saved_filter(lookup); + let mut h = f.headless(); + h.open(Path::new("stored.pcraft")).unwrap(); + let before = h.session.active().unwrap().doc.clone(); + // Session::edit is the outside-execute mutation path (an edit closure the UI or a + // linked-file change runs); the stored filter must be denied there too. + assert_denied(h.session.edit("Re-render", |d, _| { + match smart_cmds::refresh(d, layer) { + Ok(_) => Ok(()), + Err(e) => Err(e), + } + })); + assert!(Arc::ptr_eq(&before, &h.session.active().unwrap().doc)); + // A trusted session (no gate) still renders the same document. + let mut trusted = Session::new(); + trusted.add_document(doc, None); + let mut d = (*trusted.active().unwrap().doc).clone(); + assert!(smart_cmds::refresh(&mut d, layer).is_ok()); + } +} + +#[test] +fn an_ambient_map_or_lut_never_renders_unfiltered_pixels_into_the_stack_cache() { + // The denial path must fail the render before any stage is cached process-wide. + for lookup in [false, true] { + let f = Fixture::new(); + let (doc, layer) = f.saved_filter(lookup); + let mut s = Session::new(); + s.add_document(doc, None); + s.authorize = Some(authorize_desktop_engine_step); + let bytes = smart_cmds::stack_cache_bytes(); + assert_denied(s.execute("layer.smartFilter.setVisible", json!({"layer": layer.0, "visible": true}))); + assert_eq!(bytes, smart_cmds::stack_cache_bytes(), "a denied render cached no stage"); + } +} diff --git a/crates/engine/src/filters.rs b/crates/engine/src/filters.rs index 9cd6ff4600..e40be7b6ac 100644 --- a/crates/engine/src/filters.rs +++ b/crates/engine/src/filters.rs @@ -174,8 +174,9 @@ pub fn params_for(id: &str, p: &Value) -> Option { /// Runs filter command `id` with JSON `params` on `surf` (the pixel function behind every /// `filter.*` command; smart filters re-run it on re-render). `bounds` frames distortions; /// `selection` limits the result. Like layer filters, neighbourhood filters repeat the edge pixels -/// of `canvas` ∪ the surface's content instead of fading in transparency, and the output is -/// clipped to it. `None` for ids that aren't pixel filters. +/// `None` for ids that aren't pixel filters. `Err` when the requesting session's authority denies +/// the filter's params (an ambient path the session may not read): the caller must surface the +/// error rather than render unfiltered pixels. pub fn apply_filter_to_surface( id: &str, params: &Value, @@ -183,31 +184,36 @@ pub fn apply_filter_to_surface( bounds: photocraft_geom::Rect, selection: Option<&photocraft_raster::Surface>, canvas: photocraft_geom::Rect, -) -> Option { +) -> Result> { + // Replays bypass command dispatch; nested paths must still obey the requesting session's + // authority, including adjustment LUTs and plug-ins, before any resolver can read them. + crate::jobs::authorize_render_command(id, params)?; // Liquify / Puppet Warp / Perspective Warp smart filters (distort_cmds). if let Some(out) = crate::distort_cmds::apply_to_surface(id, params, surf, canvas) { - return Some(out); + return Ok(Some(out)); } // Lens Correction / Adaptive Wide Angle / Camera Raw smart filters (lens_cmds). if let Some(out) = crate::lens_cmds::apply_to_surface(id, params, surf, canvas) { - return Some(out); + return Ok(Some(out)); } // Image › Adjustments recorded as smart filters (Levels, Curves, Shadows/Highlights… from PSD). if let Some(kind) = id.strip_prefix("image.adjustments.") { - return crate::adjust_cmds::adjust_as_filter(kind, params, surf); + return Ok(crate::adjust_cmds::adjust_as_filter(kind, params, surf)); } // WebAssembly plug-in smart filters (plugin_cmds). if id == crate::plugin_cmds::RUN { - return crate::plugin_cmds::apply_to_surface(id, params, surf, selection, canvas); + return Ok(crate::plugin_cmds::apply_to_surface(id, params, surf, selection, canvas)); } - let mut fp = params_for(id, params)?; + let Some(mut fp) = params_for(id, params) else { return Ok(None) }; // A map that can no longer be read (moved or deleted since) leaves the pixels alone, like an // unknown filter; the command itself already refused an unreadable map when it was added. - crate::filters_ext::resolve_map_path(&mut fp, params).ok()?; + if crate::filters_ext::resolve_map_path(&mut fp, params).is_err() { + return Ok(None); + } let sel_bounds = selection.map(photocraft_raster::Surface::content_bounds); let content = surf.content_bounds(); let area = algo::output_area(&fp, content, bounds, sel_bounds); - Some(algo::apply_in(surf, &fp, area, bounds, selection, canvas.union(&content))) + Ok(Some(algo::apply_in(surf, &fp, area, bounds, selection, canvas.union(&content)))) } pub(crate) fn has_filterable_layer(s: &Session) -> std::result::Result<(), String> { diff --git a/crates/engine/src/jobs.rs b/crates/engine/src/jobs.rs index 70b95f2908..de2afd1556 100644 --- a/crates/engine/src/jobs.rs +++ b/crates/engine/src/jobs.rs @@ -29,6 +29,43 @@ use serde_json::{Value, json}; use crate::{EngineError, Result, Session}; +type Authorization = fn(&str, &Value) -> Result<()>; + +thread_local! { + // Smart-filter rendering has no Session argument. Carry the requesting session's gate + // through synchronous rendering and explicitly reinstall it on background workers. + static RENDER_AUTHORIZATION: std::cell::Cell> = const { std::cell::Cell::new(None) }; +} + +pub(crate) struct AuthorizationScope(Option); + +impl AuthorizationScope { + fn enter(gate: Option) -> Self { + Self(RENDER_AUTHORIZATION.with(|current| current.replace(gate.or(current.get())))) + } +} + +impl Drop for AuthorizationScope { + fn drop(&mut self) { + RENDER_AUTHORIZATION.with(|current| current.set(self.0)); + } +} + +/// [`AuthorizationScope`] entered with `gate` (or the one already installed, so trusted work on +/// this thread keeps ambient paths usable). [`Session::edit`] and the job paths use this to carry +/// the requesting session's authority into every smart-object re-render. +pub(crate) fn render_authorization_scope(gate: Option) -> AuthorizationScope { + AuthorizationScope::enter(gate) +} + +/// Recheck a stored filter's command and parameters before evaluating it or using its cache. +pub(crate) fn authorize_render_command(id: &str, params: &Value) -> Result<()> { + match RENDER_AUTHORIZATION.with(std::cell::Cell::get) { + Some(gate) => gate(id, params), + None => Ok(()), + } +} + /// Identifies one background job for the session's lifetime. #[derive(Clone, Copy, Debug, PartialEq, Eq, Hash, Serialize, serde::Deserialize)] pub struct JobId(pub u64); @@ -284,6 +321,8 @@ pub fn run( work: impl FnOnce(&JobCtx) -> Result + Send + 'static, apply: impl FnOnce(&mut Session, T) -> Result + Send + 'static, ) -> Result { + let authorization = s.authorize.or_else(|| RENDER_AUTHORIZATION.with(std::cell::Cell::get)); + let _authorization = AuthorizationScope::enter(authorization); if !s.jobs.spawn || cfg!(target_arch = "wasm32") { let t = work(&s.jobs.inline.clone().unwrap_or_default())?; return apply(s, t); @@ -299,6 +338,7 @@ pub fn run( let ctx = JobCtx::new(); let (tx, rx) = std::sync::mpsc::channel::>(); let erased: Apply = Box::new(move |s, any| { + let _authorization = AuthorizationScope::enter(authorization); let t = any.downcast::().map_err(|_| EngineError::Other("internal error: job result type mismatch".into()))?; apply(s, *t) }); @@ -311,6 +351,7 @@ pub fn run( // The work closure is moved into the thread. If spawning fails (thread limits), there is // no way to get it back, so the error is reported instead. let spawned = std::thread::Builder::new().name(format!("photocraft-job-{}", id.0)).spawn(move || { + let _authorization = AuthorizationScope::enter(authorization); let r = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| work(&wctx).map(|t| Box::new(t) as Payload))) .unwrap_or_else(|_| Err(EngineError::Other(format!("`{label_w}` failed with an internal error (logged); the document is unchanged")))); // The receiver is gone when the job was cancelled: nothing to report. @@ -652,6 +693,7 @@ impl Session { if let Some(gate) = self.authorize { gate(id, ¶ms)?; } + let _authorization = AuthorizationScope::enter(self.authorize); // Last Filter runs as the filter it repeats: journaled, recorded in an action, faded and // run as a job like that filter, so a recorded step replays the same filter anywhere // (#3089), as Transform Again records its transform (#1440). With no filter yet it falls diff --git a/crates/engine/src/lib.rs b/crates/engine/src/lib.rs index f13d61b979..b8d6167c3d 100644 --- a/crates/engine/src/lib.rs +++ b/crates/engine/src/lib.rs @@ -779,6 +779,9 @@ impl Session { /// Apply an undoable edit to the active document. pub fn edit(&mut self, label: &str, f: impl FnOnce(&mut Document, &mut Option) -> Result) -> Result { + // Every mutation path (linked-file refresh, transform, place) re-renders smart objects + // through this entry; the requesting session's authority must reach those renders. + let _authorization = crate::jobs::render_authorization_scope(self.authorize); let restrict = self.color_restrict; let sampler_map = self.sampler_map.take(); let st = self.active_mut().ok_or(EngineError::NoDocument)?; diff --git a/crates/engine/src/smart_cmds.rs b/crates/engine/src/smart_cmds.rs index e2c1cf2bfa..6ffc89e67d 100644 --- a/crates/engine/src/smart_cmds.rs +++ b/crates/engine/src/smart_cmds.rs @@ -298,28 +298,35 @@ fn blend_surfaces(base: &Surface, top: &Surface, mode: BlendMode, opacity: f32) out } -/// Runs the smart filter stack on the placed (unfiltered) content. -pub fn apply_smart_filters(placed: &Surface, sm: &SmartObject, canvas: Rect) -> Surface { +/// Runs the smart filter stack on the placed (unfiltered) content. A filter the session's +/// authority denies surfaces its error (the trusted compositor path has no gate installed, so +/// this reads ambiently only when the caller established one and it refuses). +pub fn apply_smart_filters(placed: &Surface, sm: &SmartObject, canvas: Rect) -> Result { if !sm.filters_enabled || !sm.smart_filters.iter().any(|f| f.visible) { - return placed.clone(); + return Ok(placed.clone()); } let mut cur = placed.clone(); // Edge pixels repeat at the canvas ∪ placed-content edge, as for layer filters. let extent = canvas.union(&placed.content_bounds()); for f in sm.smart_filters.iter().filter(|f| f.visible) { - cur = apply_smart_filter(f, &cur, canvas, extent); + cur = apply_smart_filter(f, &cur, canvas, extent)?; } - match sm.filter_mask.as_ref().filter(|m| m.enabled) { + Ok(match sm.filter_mask.as_ref().filter(|m| m.enabled) { Some(m) => photocraft_compose::filter_mask::mix(placed, &cur, m), None => cur, - } + }) } -/// One visible smart filter over `cur`, blended with its blend mode and opacity. -fn apply_smart_filter(f: &SmartFilter, cur: &Surface, canvas: Rect, extent: Rect) -> Surface { - // Unknown ids (e.g. Photoshop filters we don't implement) leave the pixels alone. - let Some(out) = crate::filters::apply_filter_to_surface(&f.command, &f.params, cur, canvas, None, extent) else { return cur.clone() }; - if f.blend == BlendMode::Normal && f.opacity >= 1.0 { out } else { blend_surfaces(cur, &out, f.blend, f.opacity.clamp(0.0, 1.0)) } +/// One visible smart filter over `cur`, blended with its blend mode and opacity. `Err` when the +/// requesting session's authority denies the filter's params: callers surface the error instead of +/// rendering (and caching) unfiltered pixels. +fn apply_smart_filter(f: &SmartFilter, cur: &Surface, canvas: Rect, extent: Rect) -> Result { + let out = match crate::filters::apply_filter_to_surface(&f.command, &f.params, cur, canvas, None, extent)? { + // Unknown ids (e.g. Photoshop filters we don't implement) leave the pixels alone. + Some(out) => out, + None => cur.clone(), + }; + if f.blend == BlendMode::Normal && f.opacity >= 1.0 { Ok(out) } else { Ok(blend_surfaces(cur, &out, f.blend, f.opacity.clamp(0.0, 1.0))) } } /// The source placed in document pixels, before any smart filter. @@ -377,6 +384,11 @@ pub fn render(doc: &Document, sm: &SmartObject) -> Result> { /// is cancelled. Gives the same pixels as [`apply_smart_filters`] on the placed source, but /// starts from the deepest stage of the stack already rendered (see [`stack_cache`]). fn render_in(doc: &Document, sm: &SmartObject, ctx: &JobCtx) -> Result> { + // Stored filters can come from a file or another layer. Check before looking up cached + // stages too: a trusted UI render may have populated this process-wide cache earlier. + for filter in sm.smart_filters.iter().filter(|f| sm.filters_enabled && f.visible) { + crate::jobs::authorize_render_command(&filter.command, &filter.params)?; + } let Some((name, bytes)) = source_bytes(&doc.metadata, &sm.source) else { return Ok(None) }; let (fmt, canvas) = (doc.pixel_format(), doc.bounds()); let placed_key = stack_cache::placed_key(&bytes, fmt, sm, canvas); @@ -410,7 +422,7 @@ fn render_in(doc: &Document, sm: &SmartObject, ctx: &JobCtx) -> Result Result { fn set_filter_params(s: &mut Session, p: &Value) -> Result { const CMD: &str = "layer.smartFilter.setParams"; let new = p.get("params").cloned().ok_or_else(|| bad(CMD, "pass `params` (merged into the filter's parameters)"))?; + let authorize = s.authorize; edit_filters(s, p, "Edit Smart Filter", |sm| { let i = filter_index(CMD, p, sm)?; let f = &mut sm.smart_filters[i]; @@ -1010,6 +1023,11 @@ fn set_filter_params(s: &mut Session, p: &Value) -> Result { (Value::Object(old), Value::Object(n)) => old.extend(n), (slot, n) => *slot = n, } + // Authorize the underlying command with the complete merged params, including paths + // already stored in the document, before committing the edit or starting a worker. + if let Some(gate) = authorize { + gate(&f.command, &f.params)?; + } Ok(()) }) } diff --git a/crates/engine/src/smart_cmds/tests/stack_cache.rs b/crates/engine/src/smart_cmds/tests/stack_cache.rs index 7675d71843..9465fb8387 100644 --- a/crates/engine/src/smart_cmds/tests/stack_cache.rs +++ b/crates/engine/src/smart_cmds/tests/stack_cache.rs @@ -17,7 +17,7 @@ fn uncached(s: &Session) -> Surface { let doc = &s.active().unwrap().doc; let sm = active_smart(s); let (name, bytes) = source_bytes(&doc.metadata, &sm.source).unwrap(); - apply_smart_filters(&place(&name, &bytes, doc.pixel_format(), &sm).unwrap(), &sm, doc.bounds()) + apply_smart_filters(&place(&name, &bytes, doc.pixel_format(), &sm).unwrap(), &sm, doc.bounds()).unwrap() } #[test] From 3cb3580a46ba080d7ce4a037efc0a921724bd939 Mon Sep 17 00:00:00 2001 From: PerchenkoAnton <101743951+PerchAnton@users.noreply.github.com> Date: Fri, 16 Oct 2026 04:14:05 +0300 Subject: [PATCH 39/47] fix(psd): recover layered imports with damaged merged data (#6170) --- crates/io/src/lib.rs | 6 +- crates/io/src/psd_import.rs | 12 +++ crates/io/tests/corpus.rs | 2 +- crates/io/tests/psd_damaged_merged.rs | 125 ++++++++++++++++++++++++++ crates/psd/src/file.rs | 44 ++++++++- crates/psd/tests/corpus.rs | 3 +- crates/psd/tests/merged_recovery.rs | 43 +++++++++ 7 files changed, 228 insertions(+), 7 deletions(-) create mode 100644 crates/io/tests/psd_damaged_merged.rs create mode 100644 crates/psd/tests/merged_recovery.rs diff --git a/crates/io/src/lib.rs b/crates/io/src/lib.rs index a620ddcf33..24b8b3f948 100644 --- a/crates/io/src/lib.rs +++ b/crates/io/src/lib.rs @@ -275,7 +275,11 @@ fn import_stages(name: &str, bytes: &[u8], ctl: &photocraft_raster::Interrupt, m } if is_psd(bytes) { // A merged image the import reads is decoded with the parse: a ZIP one is inflated once. - let (file, merged) = PsdFile::from_bytes_and_merged(bytes, |f| psd_import::check_group_depth(f).is_ok() && psd_import::merged_needed(f))?; + let (file, merged) = PsdFile::from_bytes_and_merged_recovering( + bytes, + |f| psd_import::check_group_depth(f).is_ok() && psd_import::merged_needed(f), + psd_import::can_recover_layers, + )?; // Nesting past the document model's cap could never be saved; reject the file with the // actionable limit. psd_import::check_group_depth(&file)?; diff --git a/crates/io/src/psd_import.rs b/crates/io/src/psd_import.rs index c20c1b7fe7..12307f32ba 100644 --- a/crates/io/src/psd_import.rs +++ b/crates/io/src/psd_import.rs @@ -764,6 +764,12 @@ pub(crate) fn merged_needed(file: &PsdFile) -> bool { !layered || file.layers().is_empty() || usize::from(h.channels) > mode.color_channels() + usize::from(file.merged_has_alpha()) } +/// A damaged merged preview need not prevent opening independently stored layers (#6152). +/// Other modes are imported flattened, so their merged image cannot be discarded. +pub(crate) fn can_recover_layers(file: &PsdFile) -> bool { + matches!(file.header.color_mode, PsdMode::Grayscale | PsdMode::Rgb | PsdMode::Cmyk | PsdMode::Lab) && file.header.depth != 1 && !file.layers().is_empty() +} + /// [`psd_to_document_with`] with the merged image already decoded (by /// [`PsdFile::from_bytes_and_merged`] with [`merged_needed`]), so it is not decoded again. pub(crate) fn psd_to_document_decoded( @@ -901,9 +907,15 @@ pub(crate) fn psd_to_document_decoded( ctl.progress(0.1); if let Err(e) = &m { cx.warn(format!("merged image could not be decoded: {e}")); + if can_recover_layers(file) { + cx.warn("extra alpha/spot channels in the merged image could not be recovered"); + } } Some(m) } else { + if let Some(Err(e)) = decoded { + cx.warn(format!("merged image could not be decoded; opened the available layers instead: {e}")); + } None }; diff --git a/crates/io/tests/corpus.rs b/crates/io/tests/corpus.rs index dd96d96c7f..710b2a8644 100644 --- a/crates/io/tests/corpus.rs +++ b/crates/io/tests/corpus.rs @@ -132,7 +132,7 @@ thread_local! { } /// Files known to be invalid upstream: their parse error is expected. -const KNOWN_BAD: &[(&str, &str)] = &[("group-divider-blend-mode.psd", "psd-tools fixture stripped to 1906 bytes: merged image data missing")]; +const KNOWN_BAD: &[(&str, &str)] = &[("group-divider-blend-mode.psd", "malformed merged image data; recovery is tested in psd_damaged_merged.rs (#6152)")]; /// One corpus directory with its own floors. struct Source { diff --git a/crates/io/tests/psd_damaged_merged.rs b/crates/io/tests/psd_damaged_merged.rs new file mode 100644 index 0000000000..e3d0970dd9 --- /dev/null +++ b/crates/io/tests/psd_damaged_merged.rs @@ -0,0 +1,125 @@ +//! #6152: intact layers can be recovered when only the merged preview is damaged. + +use photocraft_color::{ColorMode, SampleType}; +use photocraft_doc::{Document, Layer}; +use photocraft_geom::Size; +use photocraft_io::{ExportOptions, export, import}; +use photocraft_psd::{Compression, GroupSpec, PsdBuilder, PsdFile}; + +fn layered(depth: SampleType, extension: &str) -> PsdFile { + layered_in_mode(ColorMode::Rgb, depth, extension) +} + +fn layered_in_mode(mode: ColorMode, depth: SampleType, extension: &str) -> PsdFile { + let mut doc = Document::new("recovery", Size::new(3, 2), mode, depth); + let format = doc.pixel_format(); + let mut layer = Layer::raster("red pixels", doc.pixel_format()); + let mut pixel = vec![0.0; format.channels()]; + photocraft_raster::from_rgba_into(&format, [1.0, 0.0, 0.0, 1.0], &mut pixel); + layer.surface_mut().unwrap().fill_rect(doc.bounds(), &pixel); + doc.layers.push(layer); + PsdFile::from_bytes(&export(&doc, extension, &ExportOptions::default()).unwrap().bytes).unwrap() +} + +#[test] +fn truncated_merged_image_keeps_layer_pixels_at_every_depth_and_compression() { + use ColorMode::{Cmyk, Grayscale, Lab, Rgb}; + use SampleType::{F32, U8, U16}; + for extension in ["x.psd", "x.psb"] { + for (mode, depth) in + [(Rgb, U8), (Rgb, U16), (Rgb, F32), (Grayscale, U8), (Grayscale, U16), (Grayscale, F32), (Cmyk, U8), (Cmyk, U16), (Lab, U8), (Lab, U16)] + { + for compression in Compression::ALL { + let mut file = layered_in_mode(mode, depth, extension); + let intact = import(extension, &file.to_bytes().unwrap()).unwrap(); + let expected = photocraft_compose::flatten(&intact.document).px; + let pixels = file.decode_merged().unwrap(); + file.image_data = photocraft_psd::ImageData::encode(compression, &pixels, &file.header).unwrap(); + file.image_data.data.truncate(file.image_data.data.len() / 2); + let bytes = file.to_bytes().unwrap(); + assert!(PsdFile::from_bytes(&bytes).is_err(), "strict parsing stays strict"); + let result = import(extension, &bytes).expect("recover the intact layers"); + assert_eq!(result.document.layer_count(), 1); + assert_eq!(result.document.depth, depth); + assert!(result.warnings.iter().any(|w| w.contains("merged image")), "{:?}", result.warnings); + let rendered = photocraft_compose::flatten(&result.document); + assert_eq!(rendered.px, expected, "{extension} {mode:?} {depth:?} {compression:?}"); + // Saving the recovered document replaces the bad preview with a valid one. + let saved = export(&result.document, extension, &ExportOptions::default()).unwrap(); + assert!(PsdFile::from_bytes(&saved.bytes).unwrap().decode_merged().is_ok()); + } + } + } +} + +#[test] +fn corrupt_zip_merged_image_keeps_the_layers() { + let mut file = layered(SampleType::U8, "x.psd"); + file.image_data = photocraft_psd::ImageData::encode(Compression::Zip, &file.decode_merged().unwrap(), &file.header).unwrap(); + *file.image_data.data.last_mut().unwrap() ^= 0xff; + let result = import("x.psd", &file.to_bytes().unwrap()).unwrap(); + assert_eq!(result.document.layer_count(), 1); + assert!(result.warnings.iter().any(|w| w.contains("merged image"))); +} + +#[test] +fn an_empty_group_survives_a_truncated_preview() { + // The reported psd-tools fixture has only the two records of an empty group. + let mut builder = PsdBuilder::new(100, 100); + builder.begin_group(GroupSpec::new("empty group")); + builder.end_group().unwrap(); + let mut file = builder.build().unwrap(); + file.image_data.data.truncate(file.image_data.data.len() / 2); + let result = import("empty.psd", &file.to_bytes().unwrap()).unwrap(); + assert_eq!(result.document.layer_count(), 1); + assert_eq!(result.document.layers[0].name, "empty group"); + assert!(result.warnings.iter().any(|w| w.contains("merged image"))); +} + +#[test] +fn a_flattened_file_with_no_recoverable_layers_still_fails() { + let mut file = layered(SampleType::U8, "x.psd"); + file.layer_info = None; + file.image_data.data.truncate(file.image_data.data.len() / 2); + assert!(import("flat.psd", &file.to_bytes().unwrap()).is_err()); +} + +#[test] +fn modes_imported_flattened_do_not_recover_from_unusable_layer_records() { + for mode in [photocraft_psd::ColorMode::Indexed, photocraft_psd::ColorMode::Multichannel, photocraft_psd::ColorMode::Duotone] { + let mut file = layered(SampleType::U8, "x.psd"); + file.header.color_mode = mode; + file.image_data = photocraft_psd::ImageData { compression: Compression::Raw, data: Vec::new() }; + assert!(import("flat-mode.psd", &file.to_bytes().unwrap()).is_err(), "{mode:?}"); + } +} + +#[test] +fn lost_extra_channels_are_reported_while_layers_remain_usable() { + let mut file = layered(SampleType::U8, "x.psd"); + file.header.channels = 5; + file.image_data = photocraft_psd::ImageData { compression: Compression::Raw, data: Vec::new() }; + let result = import("extra-channel.psd", &file.to_bytes().unwrap()).unwrap(); + assert_eq!(result.document.layer_count(), 1); + assert!(result.document.channels.is_empty()); + assert!(result.warnings.iter().any(|w| w.contains("alpha/spot channels")), "{:?}", result.warnings); +} + +#[test] +fn truncated_layer_sections_are_not_recovered() { + let file = layered(SampleType::U8, "x.psd"); + let mut bytes = file.to_bytes().unwrap(); + bytes.truncate(bytes.len() - file.image_data.data.len() - 3); + assert!(import("broken-layers.psd", &bytes).is_err()); +} + +#[cfg(feature = "corpus")] +#[test] +fn reported_group_divider_fixture_opens_with_its_group() { + let path = std::path::Path::new(env!("CARGO_MANIFEST_DIR")).join("../../corpus/psd-tools/blend-modes/group-divider-blend-mode.psd"); + let bytes = std::fs::read(path).expect("run cargo xtask corpus --psd-tools"); + let result = import("group-divider-blend-mode.psd", &bytes).expect("#6152 opens"); + assert_eq!(result.document.layer_count(), 1); + assert!(matches!(result.document.layers[0].content, photocraft_doc::LayerContent::Group(_))); + assert!(result.warnings.iter().any(|w| w.contains("merged image"))); +} diff --git a/crates/psd/src/file.rs b/crates/psd/src/file.rs index 5a1ee15c1c..e14bc45aaf 100644 --- a/crates/psd/src/file.rs +++ b/crates/psd/src/file.rs @@ -102,6 +102,22 @@ impl PsdFile { /// Errors are those of [`Self::from_bytes`]; a merged image that passes the parse's check but /// cannot be decoded is the inner `Err`, as [`Self::decode_merged`] would return it. pub fn from_bytes_and_merged(data: &[u8], want: impl FnOnce(&PsdFile) -> bool) -> Result<(Self, Option>>)> { + Self::from_bytes_and_merged_recovering(data, want, |_| false) + } + + /// Parses like [`Self::from_bytes_and_merged`], but lets `recover` accept damage confined to + /// the merged image after all layer records and channel data have been read successfully. + /// Callers should opt in only when they can reconstruct the document from its layers. + /// + /// The original encoded image bytes are preserved and its parse error is returned as + /// `Some(Err(..))`, even when `want` did not request a decode. The caller must report that + /// error rather than silently discarding the merged image (which may hold extra channels). + /// Header/layer errors, missing compression fields and parse safety-limit errors stay fatal. + pub fn from_bytes_and_merged_recovering( + data: &[u8], + want: impl FnOnce(&PsdFile) -> bool, + recover: impl FnOnce(&PsdFile) -> bool, + ) -> Result<(Self, Option>>)> { let mut r = Reader::new(data); let header = Header::read(&mut r)?; let psb = header.version.is_psb(); @@ -161,10 +177,30 @@ impl PsdFile { let image_data = ImageData { compression: Compression::Raw, data: Vec::new() }; let mut file = PsdFile { header, color_mode_data, resources, layer_info, layer_info_placement, global_layer_mask, global_blocks, layer_mask_trailing, image_data }; - let merged = if want(&file) { Some(file.read_merged_decoded(&mut r)?) } else { None }; - if merged.is_none() { - file.image_data = ImageData::read(&mut r, &file.header)?; - } + let mut encoded = r.clone(); + let merged = if want(&file) { + file.read_merged_decoded(&mut r).map(Some) + } else { + ImageData::read(&mut r, &file.header).map(|image| { + file.image_data = image; + None + }) + }; + let merged = match merged { + Ok(merged) => merged, + Err(error) => { + if matches!(error, PsdError::LimitExceeded(_)) || !recover(&file) { + return Err(error); + } + let compression = Compression::from_u16(encoded.u16()?); + let source = encoded.peek_rest(); + let mut data = Vec::new(); + data.try_reserve_exact(source.len()).map_err(|_| PsdError::LimitExceeded("not enough memory to retain the damaged merged image"))?; + data.extend_from_slice(source); + file.image_data = ImageData { compression, data }; + Some(Err(error)) + } + }; Ok((file, merged)) } diff --git a/crates/psd/tests/corpus.rs b/crates/psd/tests/corpus.rs index 19b1e56600..90abb4fefd 100644 --- a/crates/psd/tests/corpus.rs +++ b/crates/psd/tests/corpus.rs @@ -21,7 +21,8 @@ fn collect(dir: &Path, out: &mut Vec) { /// Files known to be invalid upstream, with the reason. They are reported but /// do not fail the test. -const KNOWN_BAD: &[(&str, &str)] = &[("group-divider-blend-mode.psd", "psd-tools fixture stripped to 1906 bytes: merged image data missing")]; +const KNOWN_BAD: &[(&str, &str)] = + &[("group-divider-blend-mode.psd", "malformed merged image data; strict parsing rejects it, but the importer recovers its group (#6152)")]; #[test] fn corpus_parse_and_byte_stable() { diff --git a/crates/psd/tests/merged_recovery.rs b/crates/psd/tests/merged_recovery.rs new file mode 100644 index 0000000000..40e310d8a4 --- /dev/null +++ b/crates/psd/tests/merged_recovery.rs @@ -0,0 +1,43 @@ +//! Opt-in merged-image recovery must not weaken strict parsing or lose encoded bytes. + +use photocraft_psd::{Compression, PsdError, PsdFile, Version, compression, testgen}; + +#[test] +fn recovery_is_explicit_and_preserves_the_original_file() { + for version in [Version::Psd, Version::Psb] { + for compression in Compression::ALL { + let mut original = testgen::small(version, compression); + original.image_data.data.pop(); + let bytes = original.to_bytes().unwrap(); + let strict_error = PsdFile::from_bytes(&bytes).unwrap_err(); + for want in [false, true] { + let rejected = PsdFile::from_bytes_and_merged_recovering(&bytes, |_| want, |_| false).unwrap_err(); + assert_eq!(rejected, strict_error); + let (file, merged) = PsdFile::from_bytes_and_merged_recovering(&bytes, |_| want, |_| true).unwrap(); + assert_eq!(merged.unwrap().unwrap_err(), strict_error); + assert_eq!(file, original); + assert_eq!(file.to_bytes().unwrap(), bytes, "{version:?} {compression:?}"); + } + } + } +} + +#[test] +fn recovery_does_not_accept_missing_compression_or_incomplete_layers() { + let original = testgen::small(Version::Psd, Compression::Raw); + let bytes = original.to_bytes().unwrap(); + let merged_offset = bytes.len() - original.image_data.data.len() - 2; + for cut in [merged_offset - 1, merged_offset, merged_offset + 1] { + assert!(PsdFile::from_bytes_and_merged_recovering(&bytes[..cut], |_| true, |_| true).is_err(), "cut {cut}"); + } +} + +#[test] +fn recovery_never_suppresses_a_merged_stream_limit_error() { + let mut original = testgen::small(Version::Psd, Compression::Zip); + original.image_data.data = compression::zip_compress(&vec![0; (1 << 20) + 4096]); + let bytes = original.to_bytes().unwrap(); + for want in [false, true] { + assert!(matches!(PsdFile::from_bytes_and_merged_recovering(&bytes, |_| want, |_| true), Err(PsdError::LimitExceeded(_)))); + } +} From 0cc161e2a85ba01fcc2e92fb82cc5267ea280374 Mon Sep 17 00:00:00 2001 From: "Dylan M. Taylor" Date: Thu, 15 Oct 2026 19:53:32 -0400 Subject: [PATCH 40/47] ci: delegate FreeBSD workflow to craft-common-actions and add nix check --- .github/workflows/freebsd.yml | 42 ++++--------------- .github/workflows/nix.yml | 33 +++++++++++++++ .../tests/smart_filter_authorization.rs | 8 ++-- 3 files changed, 43 insertions(+), 40 deletions(-) create mode 100644 .github/workflows/nix.yml diff --git a/.github/workflows/freebsd.yml b/.github/workflows/freebsd.yml index fa0693f58e..970c6577a4 100644 --- a/.github/workflows/freebsd.yml +++ b/.github/workflows/freebsd.yml @@ -27,38 +27,10 @@ permissions: jobs: freebsd: - name: FreeBSD 14 (build + test + package) - runs-on: ubuntu-latest - timeout-minutes: 300 - steps: - - uses: actions/checkout@v4 - - name: Build, test and package in a FreeBSD VM - uses: vmactions/freebsd-vm@c46abacb49f09938ca4e1702d15d836285d694cc # v1 - with: - release: "14.3" - usesh: true - cpu: 4 - mem: 12288 - # Nothing to bring back; copying the target dir out of the VM fails on large builds. - copyback: false - prepare: | - pkg install -y bash git curl pkgconf cmake python3 libxkbcommon wayland libX11 libXcursor libXrandr libXi libxcb mesa-libs vulkan-loader gtk3 fontconfig freetype2 alsa-lib - curl -fsSL https://sh.rustup.rs | sh -s -- -y --profile minimal - run: | - set -e - . "$HOME/.cargo/env" - # The checkout is synced in under another owner; let tests that call git read it. - git config --global --add safe.directory "*" - rustc --version - CARGO_PROFILE_DEV_DEBUG=0 cargo build --workspace --all-targets --locked --features heif - CARGO_PROFILE_DEV_DEBUG=0 cargo test --workspace --locked --features heif --exclude photocraft-ui-egui - CARGO_PROFILE_DEV_DEBUG=0 cargo test -p photocraft-ui-egui --locked --lib - CARGO_PROFILE_DEV_DEBUG=0 cargo test -p photocraft-ui-egui --locked --doc - # The VM's software Vulkan driver crashes (SIGSEGV) when several tests set up a GPU - # device at once, so the GPU integration tests run one at a time here. - CARGO_PROFILE_DEV_DEBUG=0 cargo test -p photocraft-ui-egui --locked --test '*' -- --test-threads=1 - # The release package, as release.yml builds it. Free the debug build's disk first. - rm -rf target/debug - CARGO_BUILD_JOBS=4 bash packaging/freebsd/package.sh - tar -tzf dist/release/photocraft-*-freebsd-x86_64.tar.gz \ - | grep -E '/bin/photocraft$|/bin/photocraft-cli$|/share/applications/ai\.storyteller\.photocraft\.desktop$|/share/doc/photocraft/LICENSE-MIT$' + # Intentionally tracked at @main (not SHA-pinned) so org-wide CI improvements, security fixes, and build optimizations propagate without PR churn across all craftapp repos. + uses: storytold/craft-common-actions/.github/workflows/freebsd.yml@main + with: + craft-fonts-ref: abb83316d96aa59c1cf64784289e378fe9fa5695 + cargo-build-extra-args: "--features heif" + custom-test-command: "CARGO_PROFILE_DEV_DEBUG=0 cargo test --workspace --locked --features heif --exclude photocraft-ui-egui && CARGO_PROFILE_DEV_DEBUG=0 cargo test -p photocraft-ui-egui --locked --lib && CARGO_PROFILE_DEV_DEBUG=0 cargo test -p photocraft-ui-egui --locked --doc && CARGO_PROFILE_DEV_DEBUG=0 cargo test -p photocraft-ui-egui --locked --test '*' -- --test-threads=1" + run-package-check: true diff --git a/.github/workflows/nix.yml b/.github/workflows/nix.yml new file mode 100644 index 0000000000..18392587a8 --- /dev/null +++ b/.github/workflows/nix.yml @@ -0,0 +1,33 @@ +# Evaluates the Nix flake (no build, about a minute): `nix build` / `nix develop` stop at evaluation +# when Cargo.lock gains something nix/package.nix can't vendor, and nothing else would notice. +name: Nix + +on: + push: + branches: [main] + paths: ["flake.nix", "flake.lock", "nix/**", "Cargo.lock", ".github/workflows/nix.yml"] + pull_request: + paths: ["flake.nix", "flake.lock", "nix/**", "Cargo.lock", ".github/workflows/nix.yml"] + workflow_dispatch: + +permissions: + contents: read + +concurrency: + group: nix-${{ github.ref }} + cancel-in-progress: true + +jobs: + eval: + runs-on: ubuntu-latest + timeout-minutes: 15 + steps: + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + - uses: cachix/install-nix-action@13d8dd58da0234aa297dedd986986ccb8e7f3e24 # v31.11.1 + - name: nix flake check --no-build + run: | + if [ -f flake.nix ]; then + nix flake check --no-build --all-systems + else + echo "No flake.nix present in this repository; skipping check." + fi diff --git a/crates/automation/tests/smart_filter_authorization.rs b/crates/automation/tests/smart_filter_authorization.rs index a5c1681a52..30f17b795b 100644 --- a/crates/automation/tests/smart_filter_authorization.rs +++ b/crates/automation/tests/smart_filter_authorization.rs @@ -230,11 +230,9 @@ fn a_refresh_outside_command_execution_surfaces_the_denial_instead_of_rendering_ let before = h.session.active().unwrap().doc.clone(); // Session::edit is the outside-execute mutation path (an edit closure the UI or a // linked-file change runs); the stored filter must be denied there too. - assert_denied(h.session.edit("Re-render", |d, _| { - match smart_cmds::refresh(d, layer) { - Ok(_) => Ok(()), - Err(e) => Err(e), - } + assert_denied(h.session.edit("Re-render", |d, _| match smart_cmds::refresh(d, layer) { + Ok(_) => Ok(()), + Err(e) => Err(e), })); assert!(Arc::ptr_eq(&before, &h.session.active().unwrap().doc)); // A trusted session (no gate) still renders the same document. From 57437ea1a9828298b46673bda891f2a520de92fd Mon Sep 17 00:00:00 2001 From: Tanish Manku Date: Thu, 15 Oct 2026 21:05:39 -0700 Subject: [PATCH 41/47] fix(ui-egui): canvas textures fit the device's texture limit; GPU tests hold the GPU lock on the app's device (#5954) - The CPU canvas texture and the crop's beyond-canvas bands were reduced to fit MAX_TEXTURE (4096) whatever the device allows. On a device with a smaller limit (2048 on some older GPUs and WebGL contexts) a 2600 px document was uploaded at full size: egui asserts and the backend can't create it. They now fit the smaller of the two. Test: cpu_canvas_texture_fits_the_device_limit. - background_redraw and navigator GPU tests built their harness with egui's default wgpu limits instead of the app's wgpu_setup (the adapter's own limits), and the GPU canvas lost the device on it ("Device is lost", every run on an RTX 5090). They now use wgpu_setup. - Eleven tests built a wgpu device without photocraft_testkit::gpu_lock, which testkit requires so devices aren't created concurrently; they now hold it. --- crates/ui-egui/src/background_redraw_tests.rs | 25 ++++++++------ crates/ui-egui/src/canvas.rs | 34 +++++++++++++++++-- .../ui-egui/src/clipping_indicator_tests.rs | 1 + .../src/discard_ui/tests/tiff_tests.rs | 1 + crates/ui-egui/src/gpu_canvas_mip_tests.rs | 1 + crates/ui-egui/src/navigator_gpu_tests.rs | 34 +++++++++++-------- crates/ui-egui/src/panel_docking_tests.rs | 3 ++ crates/ui-egui/src/panels.rs | 1 + crates/ui-egui/src/swap_arrow.rs | 1 + crates/ui-egui/src/type_tool_tests.rs | 1 + crates/ui-egui/src/widgets.rs | 1 + 11 files changed, 76 insertions(+), 27 deletions(-) diff --git a/crates/ui-egui/src/background_redraw_tests.rs b/crates/ui-egui/src/background_redraw_tests.rs index df5d467e10..650fccaa33 100644 --- a/crates/ui-egui/src/background_redraw_tests.rs +++ b/crates/ui-egui/src/background_redraw_tests.rs @@ -41,16 +41,21 @@ fn gpu_app() -> Option { let observer = Arc::new(std::sync::Mutex::new(None)); let observer_out = observer.clone(); let built = std::panic::catch_unwind(std::panic::AssertUnwindSafe(move || { - egui_kittest::Harness::builder().with_size(egui::vec2(1000.0, 700.0)).with_pixels_per_point(1.0).with_max_steps(64).wgpu().build_eframe(move |cc| { - PhotocraftApp::setup_context(&cc.egui_ctx, Default::default()); - let mut app = PhotocraftApp::new(photocraft_engine::Session::new(), Default::default()); - if let Some(rs) = cc.wgpu_render_state.as_ref() { - *observer_out.lock().expect("observer") = Some(GpuCanvas::new(rs)); - app.set_wgpu(rs.clone()); - } - app.background_jobs = true; - app - }) + egui_kittest::Harness::builder() + .with_size(egui::vec2(1000.0, 700.0)) + .with_pixels_per_point(1.0) + .with_max_steps(64) + .wgpu_setup(crate::gpu_canvas::wgpu_setup()) + .build_eframe(move |cc| { + PhotocraftApp::setup_context(&cc.egui_ctx, Default::default()); + let mut app = PhotocraftApp::new(photocraft_engine::Session::new(), Default::default()); + if let Some(rs) = cc.wgpu_render_state.as_ref() { + *observer_out.lock().expect("observer") = Some(GpuCanvas::new(rs)); + app.set_wgpu(rs.clone()); + } + app.background_jobs = true; + app + }) })); let Ok(harness) = built else { eprintln!("skipping: no GPU adapter"); diff --git a/crates/ui-egui/src/canvas.rs b/crates/ui-egui/src/canvas.rs index 391ad464fd..3f4ae7a34a 100644 --- a/crates/ui-egui/src/canvas.rs +++ b/crates/ui-egui/src/canvas.rs @@ -15,6 +15,13 @@ use crate::state::{Tool, View}; /// Largest texture side we upload; bigger documents display downsampled until the GPU path lands. pub const MAX_TEXTURE: u32 = 4096; +/// The largest texture side to upload on this device: [`MAX_TEXTURE`], or less where the GPU +/// allows less (2048 on some older or low-end GPUs and WebGL contexts). Past the device's limit +/// egui asserts and the backend fails to create the texture. +fn texture_side(ctx: &egui::Context) -> u32 { + u32::try_from(ctx.input(|i| i.max_texture_side)).unwrap_or(MAX_TEXTURE).clamp(1, MAX_TEXTURE) +} + pub(crate) struct ClonePreviewCache { key: String, texture: egui::TextureHandle, @@ -1421,7 +1428,7 @@ pub fn ensure_texture(app: &mut PhotocraftApp, ctx: &egui::Context, idx: usize, if cache.tex_revision != revision || cache.texture.is_none() || cache.tex_preview_key != preview_key { let t0 = crate::gpu_canvas::now_ms(); let longest = doc.size.width.max(doc.size.height); - let factor = longest.div_ceil(MAX_TEXTURE).max(1); + let factor = longest.div_ceil(texture_side(ctx)).max(1); let (w, h) = ((doc.size.width / factor).max(1), (doc.size.height / factor).max(1)); let current = cache.texture.as_mut().filter(|t| t.size() == [w as usize, h as usize]); if let (Some(d), Some(t)) = (damage, current) { @@ -4520,8 +4527,8 @@ fn draw_beyond_canvas(app: &mut PhotocraftApp, painter: &egui::Painter, xf: &Vie None => { let t0 = crate::gpu_canvas::now_ms(); let extent = photocraft_engine::extra_cmds::reveal_all_bounds(&doc); - // One texel per `k` document pixels keeps every texture within MAX_TEXTURE. - let k = extent.width().max(extent.height()).div_ceil(MAX_TEXTURE).max(1); + // One texel per `k` document pixels keeps every texture within the device's side. + let k = extent.width().max(extent.height()).div_ceil(texture_side(&ctx)).max(1); let proxy = (k > 1).then(|| photocraft_compose::proxy::proxy_document(&doc, k)); let src = proxy.as_ref().unwrap_or(&*doc); let k = k as i32; @@ -6525,6 +6532,25 @@ mod tests { assert_eq!(uploads(&ctx), vec![(true, crisp)], "a partial update keeps the crisp sampling"); } + /// A device that allows smaller textures than MAX_TEXTURE (2048 on some older GPUs and WebGL + /// contexts, and on the fallback adapter the GPU tests sometimes get under load): the canvas + /// texture is reduced to fit it. A 2600 px document was uploaded at full size, which egui + /// asserts on and the backend can't create. + #[test] + fn cpu_canvas_texture_fits_the_device_limit() { + for (side, (w, h), want) in [(2048, (2600, 1700), [1300, 850]), (2048, (5000, 2000), [1666, 666]), (8192, (5000, 2000), [2500, 1000])] { + let mut app = PhotocraftApp::new(photocraft_engine::Session::new(), Default::default()); + app.run("file.new", json!({"width": w, "height": h})).unwrap(); + let ctx = egui::Context::default(); + ctx.input_mut(|i| i.max_texture_side = side); + let (tex, _) = ensure_texture(&mut app, &ctx, 0, None).unwrap(); + let mut delta = ctx.tex_manager().write().take_delta(); + let sizes: Vec<_> = delta.set.get(&tex).into_iter().flatten().map(|d| d.image.size()).collect(); + delta.clear(); + assert_eq!(sizes, vec![want], "{w}x{h} on a {side} px device"); + } + } + #[test] fn cpu_canvas_of_a_reduced_document_stays_bilinear() { // Over MAX_TEXTURE the texture holds one texel per `factor` pixels: a texel is not a @@ -6542,6 +6568,7 @@ mod tests { #[test] fn gpu_display_luts_are_per_display() { + let _gpu = photocraft_testkit::gpu_lock(); let Ok(rs) = std::panic::catch_unwind(|| egui_kittest::wgpu::create_render_state(crate::gpu_canvas::wgpu_setup(), Default::default())) else { eprintln!("skipping: no GPU adapter"); return; @@ -7460,6 +7487,7 @@ mod tests { // must update only the reduced pixels it touched. let mut app = PhotocraftApp::new(photocraft_engine::Session::new(), Default::default()); let ctx = egui::Context::default(); + ctx.input_mut(|i| i.max_texture_side = 8192); app.run("file.new", json!({"width": 4097, "height": 90, "background": "transparent"})).unwrap(); let tilt = json!({"size": 30, "hardness": 1.0, "spacing": 0.05, "shapeDynamics": {"enabled": true, "size": {"control": "penTilt"}}}); app.run("tools.setBrush", json!({ "brush": tilt })).unwrap(); diff --git a/crates/ui-egui/src/clipping_indicator_tests.rs b/crates/ui-egui/src/clipping_indicator_tests.rs index 9bfb600b6f..7395a533de 100644 --- a/crates/ui-egui/src/clipping_indicator_tests.rs +++ b/crates/ui-egui/src/clipping_indicator_tests.rs @@ -83,6 +83,7 @@ fn alt_click_clipping_still_creates_releases_and_undoes_without_moving_rows() { #[test] #[ignore = "writes synthetic English Layers-panel screenshots for before/after inspection"] fn write_clipping_indicator_preview() { + let _gpu = photocraft_testkit::gpu_lock(); let output = std::path::Path::new(env!("CARGO_MANIFEST_DIR")).join("../../plan/evidence/clipping"); std::fs::create_dir_all(&output).unwrap(); for theme in crate::theme::ThemeKind::ALL { diff --git a/crates/ui-egui/src/discard_ui/tests/tiff_tests.rs b/crates/ui-egui/src/discard_ui/tests/tiff_tests.rs index 73571fa751..f42de68ea0 100644 --- a/crates/ui-egui/src/discard_ui/tests/tiff_tests.rs +++ b/crates/ui-egui/src/discard_ui/tests/tiff_tests.rs @@ -7,6 +7,7 @@ use std::{cell::RefCell, rc::Rc}; type Writes = Rc)>>>; fn tiff_prompt(command: &str) -> (Prompted, Writes, DocId, DocId) { + let _gpu = photocraft_testkit::gpu_lock(); let mut app = app_with_docs(2); make_dirty(&mut app, 0); let (saved, other) = (doc_id(&app, 0), doc_id(&app, 1)); diff --git a/crates/ui-egui/src/gpu_canvas_mip_tests.rs b/crates/ui-egui/src/gpu_canvas_mip_tests.rs index a1c3e8ee14..82e911bae9 100644 --- a/crates/ui-egui/src/gpu_canvas_mip_tests.rs +++ b/crates/ui-egui/src/gpu_canvas_mip_tests.rs @@ -51,6 +51,7 @@ fn read_mips(gpu: &GpuCanvas, key: u64) -> Vec> { #[test] fn canvas_mips_preserve_opaque_pixels_and_partial_updates() { + let _gpu = photocraft_testkit::gpu_lock(); let rs = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| egui_kittest::wgpu::create_render_state(wgpu_setup(), Default::default()))); let Ok(rs) = rs else { eprintln!("skipping mip readback: no GPU adapter"); diff --git a/crates/ui-egui/src/navigator_gpu_tests.rs b/crates/ui-egui/src/navigator_gpu_tests.rs index 51326597f4..454181ff72 100644 --- a/crates/ui-egui/src/navigator_gpu_tests.rs +++ b/crates/ui-egui/src/navigator_gpu_tests.rs @@ -9,20 +9,25 @@ type Harness = egui_kittest::Harness<'static, PhotocraftApp>; fn harness() -> Option { let built = std::panic::catch_unwind(|| { - egui_kittest::Harness::builder().with_size(egui::vec2(1400.0, 900.0)).with_pixels_per_point(1.0).with_max_steps(64).wgpu().build_eframe(|cc| { - PhotocraftApp::setup_context(&cc.egui_ctx, Default::default()); - let mut app = PhotocraftApp::new(photocraft_engine::Session::new(), crate::Services::default()); - if let Some(rs) = cc.wgpu_render_state.as_ref() { - app.set_wgpu(rs.clone()); - } - app.ui.panels.navigator = true; - app.run("file.new", json!({"width": 400, "height": 300, "background": "white"})).unwrap(); - app.run("select.rect", json!({"x": 0, "y": 0, "width": 200, "height": 300})).unwrap(); - app.run("edit.fill", json!({"color": "#ff0000"})).unwrap(); - app.run("select.deselect", json!({})).unwrap(); - app.sync_views(); - app - }) + egui_kittest::Harness::builder() + .with_size(egui::vec2(1400.0, 900.0)) + .with_pixels_per_point(1.0) + .with_max_steps(64) + .wgpu_setup(crate::gpu_canvas::wgpu_setup()) + .build_eframe(|cc| { + PhotocraftApp::setup_context(&cc.egui_ctx, Default::default()); + let mut app = PhotocraftApp::new(photocraft_engine::Session::new(), crate::Services::default()); + if let Some(rs) = cc.wgpu_render_state.as_ref() { + app.set_wgpu(rs.clone()); + } + app.ui.panels.navigator = true; + app.run("file.new", json!({"width": 400, "height": 300, "background": "white"})).unwrap(); + app.run("select.rect", json!({"x": 0, "y": 0, "width": 200, "height": 300})).unwrap(); + app.run("edit.fill", json!({"color": "#ff0000"})).unwrap(); + app.run("select.deselect", json!({})).unwrap(); + app.sync_views(); + app + }) }); match built { Ok(h) => Some(h), @@ -47,6 +52,7 @@ fn near(a: [u8; 4], b: [u8; 3]) -> bool { #[test] fn navigator_draws_the_gpu_canvas_texture_and_follows_edits() { + let _gpu = photocraft_testkit::gpu_lock(); let Some(mut h) = harness() else { return }; h.run_steps(8); if !h.state().perf.gpu { diff --git a/crates/ui-egui/src/panel_docking_tests.rs b/crates/ui-egui/src/panel_docking_tests.rs index c6feeff633..a4ae378eda 100644 --- a/crates/ui-egui/src/panel_docking_tests.rs +++ b/crates/ui-egui/src/panel_docking_tests.rs @@ -175,6 +175,7 @@ fn shared_layout_keeps_panel_identity_across_themes_and_respects_workspace_lock( /// Explicit offscreen evidence; this creates no native windows. #[test] fn capture_custom_panel_docking_visual_fixtures() { + let _gpu = photocraft_testkit::gpu_lock(); let Some(directory) = std::env::var_os("CRAFT_UI_DOCKING_FIXTURES").map(std::path::PathBuf::from) else { return }; std::fs::create_dir_all(&directory).unwrap(); for theme in crate::theme::ThemeKind::ALL { @@ -211,6 +212,7 @@ fn capture_custom_panel_docking_visual_fixtures() { #[test] fn floating_group_titles_are_opaque_in_studio_themes() { + let _gpu = photocraft_testkit::gpu_lock(); // Studio's tab strip is transparent over the dock's card; a floating group must not show the canvas (#4509). let mut app = app(); app.run("window.panel.float", json!({"panel":"swatches","x":40,"y":60,"width":300,"height":300})).unwrap(); @@ -346,6 +348,7 @@ fn splitting_a_short_column_keeps_the_layers_footer_visible_and_usable() { #[test] fn populated_canvas_keeps_a_real_opacity_edit_after_floating_and_redocking() { + let _gpu = photocraft_testkit::gpu_lock(); let mut app = app(); app.ui.theme = crate::theme::ThemeKind::Studio; app.run("file.new", json!({"width":480,"height":360})).unwrap(); diff --git a/crates/ui-egui/src/panels.rs b/crates/ui-egui/src/panels.rs index ed35a68804..73eb4c971f 100644 --- a/crates/ui-egui/src/panels.rs +++ b/crates/ui-egui/src/panels.rs @@ -6003,6 +6003,7 @@ mod compact_visual_regressions { #[test] fn capture_compact_visual_fixtures() { + let _gpu = photocraft_testkit::gpu_lock(); let Some(directory) = std::env::var_os("CRAFT_UI_VISUAL_FIXTURES").map(std::path::PathBuf::from) else { return }; std::fs::create_dir_all(&directory).unwrap(); for kind in crate::theme::ThemeKind::ALL { diff --git a/crates/ui-egui/src/swap_arrow.rs b/crates/ui-egui/src/swap_arrow.rs index ef0bc76fb6..a5bd105232 100644 --- a/crates/ui-egui/src/swap_arrow.rs +++ b/crates/ui-egui/src/swap_arrow.rs @@ -66,6 +66,7 @@ mod tests { #[test] fn rendered_glyph_has_a_left_head_and_a_lower_right_head() { + let _gpu = photocraft_testkit::gpu_lock(); let mut h = egui_kittest::Harness::builder().with_size(vec2(32.0, 32.0)).wgpu().build_ui(|ui| { paint(ui.painter(), pos2(8.0, 8.0), Color32::WHITE); }); diff --git a/crates/ui-egui/src/type_tool_tests.rs b/crates/ui-egui/src/type_tool_tests.rs index c7b54c8871..7a23c9ccc3 100644 --- a/crates/ui-egui/src/type_tool_tests.rs +++ b/crates/ui-egui/src/type_tool_tests.rs @@ -1130,6 +1130,7 @@ fn temporary_type_transform_captures_press_and_rejects_collapsed_geometry() { #[test] #[ignore = "writes visual evidence to target/issue-1091; run explicitly for visual QA"] fn temporary_type_transform_visual_evidence() { + let _gpu = photocraft_testkit::gpu_lock(); let out = std::path::Path::new(env!("CARGO_MANIFEST_DIR")).join("../../target/issue-1091"); std::fs::create_dir_all(&out).unwrap(); for (index, theme) in crate::theme::ThemeKind::ALL.into_iter().enumerate() { diff --git a/crates/ui-egui/src/widgets.rs b/crates/ui-egui/src/widgets.rs index 6e841292e7..2acbdf3c8b 100644 --- a/crates/ui-egui/src/widgets.rs +++ b/crates/ui-egui/src/widgets.rs @@ -1934,6 +1934,7 @@ mod tests { /// `%`'s, so the number lines up with the box and its unit (#5150). #[test] fn value_field_digits_ink_is_centred_and_level_with_the_unit() { + let _gpu = photocraft_testkit::gpu_lock(); let scale = 4.0; for kind in [crate::theme::ThemeKind::Pro, crate::theme::ThemeKind::Studio] { let mut h = Harness::builder().with_size(egui::vec2(100.0, 40.0)).with_pixels_per_point(scale).wgpu().build_ui_state( From 297520b9c82d91f9b8c8a44ddd13dce0289b1c87 Mon Sep 17 00:00:00 2001 From: FinnNk Date: Fri, 16 Oct 2026 05:05:51 +0100 Subject: [PATCH 42/47] perf(gpu): cap the GPU memory budgets by a discrete GPU's own memory (#5234) * perf(gpu): cap the GPU memory budgets by a discrete GPU's own memory The compositor and canvas budgets came from system RAM only, so a 16 GB card in a 96 GB machine planned for up to 8 GB of compositor pages plus 24 GB of display textures. Detect a discrete adapter's dedicated memory (DXGI on Windows through winsafe, amdgpu sysfs on Linux) and cap each budget at three eighths of it. PHOTOCRAFT_VRAM_MB simulates a smaller card. Fixes #4536. Co-Authored-By: Claude Opus 5.5 * perf(gpu): keep a 16-bit canvas on the GPU in 8-bit when floats don't fit With the canvas budget capped by a small card's memory, a 16-bit document under the float-canvas pixel limit could need more than the budget as Rgba16Float and fall back to the CPU canvas although its Rgba8Unorm set fits. Choose the float canvas only when it fits the budget, which is now shared atomically so format_for can read it while the renderer is locked. Co-Authored-By: Claude Opus 5.5 * perf(gpu): detect a card's memory once per adapter, and test the Linux reader video_memory built a DXGI factory each time it was asked, three times at startup; it now remembers the answer per (vendor, device), so a GPU retry that picks another adapter still gets its own. The amdgpu sysfs reader takes the drm folder as a parameter and is tested against a temporary folder: the matching card's VRAM, a card without the file, garbage ids, a malformed size, no match and no folder. Co-Authored-By: Claude Opus 5.5 --------- Co-authored-by: Claude Opus 5.5 --- crates/ui-egui/Cargo.toml | 2 +- crates/ui-egui/examples/huge_doc_bench.rs | 12 +- crates/ui-egui/src/canvas.rs | 7 +- crates/ui-egui/src/gpu_canvas.rs | 216 ++++++++++++++++++---- crates/ui-egui/tests/canvas_16f.rs | 21 +++ 5 files changed, 221 insertions(+), 37 deletions(-) diff --git a/crates/ui-egui/Cargo.toml b/crates/ui-egui/Cargo.toml index aeefacaf2f..61f21885f9 100644 --- a/crates/ui-egui/Cargo.toml +++ b/crates/ui-egui/Cargo.toml @@ -47,7 +47,7 @@ sys-locale = "=0.3.2" # Installed RAM for the GPU memory budget (`gpu_canvas::physical_memory`): the safe binding the # desktop app already uses, pinned to the same version. [target.'cfg(target_os = "windows")'.dependencies] -winsafe = { version = "=0.0.29", default-features = false, features = ["kernel"] } +winsafe = { version = "=0.0.29", default-features = false, features = ["kernel", "dxgi"] } [dev-dependencies] # egui's own default fonts in tests: the oracle for egui_fonts.rs, and contexts made with diff --git a/crates/ui-egui/examples/huge_doc_bench.rs b/crates/ui-egui/examples/huge_doc_bench.rs index 0454aefc3f..6b0d03cda2 100644 --- a/crates/ui-egui/examples/huge_doc_bench.rs +++ b/crates/ui-egui/examples/huge_doc_bench.rs @@ -200,9 +200,17 @@ fn main() { .map(|s| s.tiles().map(|(_, t)| t.bytes().len() as u64).sum::()) .sum(); let ram = photocraft_ui_egui::gpu_canvas::physical_memory(); - let budget = photocraft_ui_egui::gpu_canvas::memory_budget(allowance, pixels, ram); + let vram = g.video_memory(); + let budget = photocraft_ui_egui::gpu_canvas::memory_budget(allowance, pixels, ram, vram); g.set_memory_budget(budget); - println!("RAM {} MB, document pixels {} MB, Memory Usage {} MB -> GPU budget {} MB", ram.unwrap_or(0) >> 20, pixels >> 20, allowance >> 20, budget >> 20); + println!( + "RAM {} MB, VRAM {} MB, document pixels {} MB, Memory Usage {} MB -> GPU budget {} MB", + ram.unwrap_or(0) >> 20, + vram.unwrap_or(0) >> 20, + pixels >> 20, + allowance >> 20, + budget >> 20 + ); let first_view = Rect::from_xywh((w as f32 * 0.1) as i32, (h as f32 * 0.1) as i32, 2560, 1440); g.set_focus(Some(if op == "view" { first_view } else { doc.bounds() })); diff --git a/crates/ui-egui/src/canvas.rs b/crates/ui-egui/src/canvas.rs index 3f4ae7a34a..a85c25c3c4 100644 --- a/crates/ui-egui/src/canvas.rs +++ b/crates/ui-egui/src/canvas.rs @@ -1674,8 +1674,9 @@ fn ensure_gpu(app: &mut PhotocraftApp, ctx: &egui::Context, idx: usize, visible: } /// Point the wgpu compositor at what the view shows, and size its memory budget from Memory -/// Usage, the document's pixels and History, and physical memory: on full refreshes (pixels -/// counted once per structural change, not per brush dab) and whenever the preference changes. +/// Usage, the document's pixels and History, physical memory and the GPU's own: on full refreshes +/// (pixels counted once per structural change, not per brush dab) and whenever the preference +/// changes. fn gpu_budget(app: &mut PhotocraftApp, gpu: &crate::gpu_canvas::GpuCanvas, idx: usize, partial: bool, visible: DRect) { gpu.set_focus(Some(visible)); let allowance = u64::from(app.session.prefs().performance.memory_usage_mb).saturating_mul(1 << 20); @@ -1684,7 +1685,7 @@ fn gpu_budget(app: &mut PhotocraftApp, gpu: &crate::gpu_canvas::GpuCanvas, idx: } let Some(st) = app.session.documents().get(idx) else { return }; let pixels = st.memory_bytes() as u64; - let budget = crate::gpu_canvas::memory_budget(allowance, pixels, crate::gpu_canvas::physical_memory()); + let budget = crate::gpu_canvas::memory_budget(allowance, pixels, crate::gpu_canvas::physical_memory(), gpu.video_memory()); gpu.set_memory_budget(budget); app.perf.gpu_budget_mb = budget >> 20; app.perf.gpu_budget_allowance = allowance; diff --git a/crates/ui-egui/src/gpu_canvas.rs b/crates/ui-egui/src/gpu_canvas.rs index 391b0b64a3..e53e89888f 100644 --- a/crates/ui-egui/src/gpu_canvas.rs +++ b/crates/ui-egui/src/gpu_canvas.rs @@ -27,6 +27,7 @@ use std::collections::HashMap; use std::num::NonZeroU64; +use std::sync::atomic::Ordering; use eframe::egui_wgpu::{self, CallbackResources, CallbackTrait, RenderState, ScreenDescriptor}; use eframe::wgpu; @@ -110,6 +111,11 @@ pub struct GpuCanvas { tile: u32, high: HighPolicy, health: photocraft_gpu::DeviceHealth, + /// [`video_memory`] of the adapter, detected once. + vram: Option, + /// [`canvas_budget`], shared with the paint callback's resources: [`GpuCanvas::format_for`] + /// reads it while the renderer is locked. + canvas_budget: std::sync::Arc, } /// Whether `regenerate_mips` builds each level in a scratch texture and copies it back: on @@ -158,9 +164,16 @@ impl GpuCanvas { res.health = health.clone(); let info = rs.adapter.get_info(); res.separate_mip_targets = separate_mip_targets(info.backend, info.vendor, std::env::var("PHOTOCRAFT_SEPARATE_MIPS").ok().as_deref()); + let vram = video_memory(&info); + res.canvas_budget.store(canvas_budget(physical_memory(), vram), Ordering::Relaxed); + let budget = res.canvas_budget.clone(); rs.renderer.write().callback_resources.insert(res); - log::info!("gpu canvas: target {:?}, max texture {max}, tile {tile}, 16F canvas {high:?}", rs.target_format); - Self { rs: rs.clone(), tile, high, health } + log::info!( + "gpu canvas: target {:?}, max texture {max}, tile {tile}, 16F canvas {high:?}, video memory {}", + rs.target_format, + vram.map_or_else(|| "unknown".to_owned(), |b| format!("{} MB", b >> 20)) + ); + Self { rs: rs.clone(), tile, high, health, vram, canvas_budget: budget } } /// The device's health flag (shared with the wgpu compositor and the paint callback). @@ -192,6 +205,12 @@ impl GpuCanvas { self.rs.adapter.get_info() } + /// The adapter's own memory where it has one and the platform reports it (see + /// [`video_memory`]); it caps [`memory_budget`] and [`canvas_budget`]. + pub fn video_memory(&self) -> Option { + self.vram + } + /// The canvas texture format for a document of `depth` and `size`: `Rgba16Float` for 16/32-bit /// documents when the adapter supports it and the document fits [`F16_BUDGET_PX`], else /// `Rgba8Unorm`. @@ -204,7 +223,11 @@ impl GpuCanvas { let px = size[0] as u64 * size[1] as u64; let high = match self.high { HighPolicy::Off => false, - HighPolicy::Budget => px <= F16_BUDGET_PX, + // Within the canvas budget too: a 16-bit canvas a small card can't hold as floats + // stays on the GPU as `Rgba8Unorm` rather than falling back to the CPU canvas. + HighPolicy::Budget => { + px <= F16_BUDGET_PX && canvas_texture_bytes(size, self.tile, texel_bytes(FORMAT_HIGH)) <= self.canvas_budget.load(Ordering::Relaxed) + } HighPolicy::Always => true, }; if high && depth != photocraft_doc::SampleType::U8 { FORMAT_HIGH } else { FORMAT } @@ -240,10 +263,7 @@ impl GpuCanvas { /// Set the GPU bytes one document's display texture set may take (see [`canvas_budget`]). pub fn set_canvas_budget(&self, bytes: u64) { - let mut r = self.rs.renderer.write(); - if let Some(res) = r.callback_resources.get_mut::() { - res.canvas_budget = bytes; - } + self.canvas_budget.store(bytes, Ordering::Relaxed); } /// Whether `doc`'s display textures fit [`canvas_budget`]; when they don't, nothing is @@ -252,7 +272,7 @@ impl GpuCanvas { let size = [doc.size.width, doc.size.height]; let bytes = canvas_texture_bytes(size, self.tile, texel_bytes(self.format_for(doc.depth, size))); let r = self.rs.renderer.read(); - r.callback_resources.get::().is_some_and(|res| bytes <= res.canvas_budget) + r.callback_resources.get::().is_some_and(|res| bytes <= res.canvas_budget.load(Ordering::Relaxed)) } /// Cap lower-stack composite reuse; zero disables it for profiling the uncached path. @@ -934,6 +954,9 @@ pub struct GpuInfo { pub device_type: String, /// Driver name and version as the driver reports them. pub driver: String, + /// The adapter's own memory in MB ([`video_memory`]): discrete GPUs where the platform + /// reports it. It caps the GPU memory budgets. + pub video_memory_mb: Option, /// `performance.gpuBackend` at launch. pub preference: String, /// The backend startup chose ("auto", "dx12", "cpu", …, or "env" for `WGPU_BACKEND`). @@ -964,6 +987,7 @@ impl GpuInfo { } .to_string(); self.driver = [info.driver.as_str(), info.driver_info.as_str()].iter().filter(|s| !s.is_empty()).copied().collect::>().join(" "); + self.video_memory_mb = video_memory(info).map(|b| b >> 20); } /// Human-readable lines for Help › System Info. @@ -1196,13 +1220,22 @@ pub const OVER_BUDGET: &str = "layers exceed the GPU memory budget; full refresh /// GPU memory the wgpu compositor may hold for layer pages and effect maps: what Memory Usage /// (`allowance`, Preferences › Performance) leaves after the document's pixels and History -/// (`pixels`), at most a quarter of physical memory (`ram`; 16 GB assumed when unknown), and at -/// least [`MIN_GPU_BUDGET`]. On unified-memory machines these textures share RAM with the -/// document, so a huge document keeps its layers on the GPU only as far as memory allows; the -/// rest is uploaded per refresh as the view needs it. -pub fn memory_budget(allowance: u64, pixels: u64, ram: Option) -> u64 { +/// (`pixels`), at most a quarter of physical memory (`ram`; 16 GB assumed when unknown) and +/// [`vram_share`] of a discrete GPU's own memory (`vram`, from [`video_memory`]), and at least +/// [`MIN_GPU_BUDGET`]. On unified-memory machines these textures share RAM with the document, so +/// a huge document keeps its layers on the GPU only as far as memory allows; the rest is +/// uploaded per refresh as the view needs it. +pub fn memory_budget(allowance: u64, pixels: u64, ram: Option, vram: Option) -> u64 { let ram = ram.unwrap_or(16 << 30); - allowance.saturating_sub(pixels).min(ram / 4).max(MIN_GPU_BUDGET) + allowance.saturating_sub(pixels).min(ram / 4).min(vram.map_or(u64::MAX, vram_share)).max(MIN_GPU_BUDGET) +} + +/// The share of a discrete GPU's memory each of [`memory_budget`] and [`canvas_budget`] may +/// plan for: three eighths, so the two together leave a quarter of the card to the window's +/// swapchain, egui and other applications instead of over-committing it and letting the driver +/// page (#4536). +pub fn vram_share(vram: u64) -> u64 { + vram / 8 * 3 } /// Why a document whose display textures don't fit [`canvas_budget`] is drawn on the CPU canvas. @@ -1220,11 +1253,12 @@ pub const REFRESH_DEFERRED: &str = "deferred"; pub const DEFER_PX: u64 = 1 << 22; /// GPU bytes one document's display texture set (every tile and mip level) may take: a quarter -/// of physical memory (`ram`; 16 GB assumed when unknown), at least [`MIN_GPU_BUDGET`]. The set -/// covers the whole canvas at full resolution, so its size follows the declared canvas, not the -/// layer pixels; a larger canvas is drawn on the (reduced) CPU canvas instead (#2934). -pub fn canvas_budget(ram: Option) -> u64 { - (ram.unwrap_or(16 << 30) / 4).max(MIN_GPU_BUDGET) +/// of physical memory (`ram`; 16 GB assumed when unknown) and at most [`vram_share`] of a +/// discrete GPU's own memory (`vram`), at least [`MIN_GPU_BUDGET`]. The set covers the whole +/// canvas at full resolution, so its size follows the declared canvas, not the layer pixels; a +/// larger canvas is drawn on the (reduced) CPU canvas instead (#2934). +pub fn canvas_budget(ram: Option, vram: Option) -> u64 { + (ram.unwrap_or(16 << 30) / 4).min(vram.map_or(u64::MAX, vram_share)).max(MIN_GPU_BUDGET) } /// GPU bytes of a display texture set for a `size` canvas in `tile`-sided tiles of `bpp` bytes @@ -1288,6 +1322,69 @@ fn detect_physical_memory() -> Option { None } +/// The memory of `adapter` itself, in bytes, when it is a discrete GPU and the platform reports +/// it: DXGI's dedicated video memory on Windows, amdgpu's VRAM size on Linux. `None` for +/// integrated and software adapters, whose textures live in system memory (the RAM-based +/// budgets already cover them), and where nothing reports it. `PHOTOCRAFT_VRAM_MB` overrides it +/// for any adapter (to simulate a smaller card). +pub fn video_memory(adapter: &wgpu::AdapterInfo) -> Option { + if let Some(mb) = std::env::var("PHOTOCRAFT_VRAM_MB").ok().and_then(|v| v.trim().parse::().ok()) { + return mb.checked_mul(1 << 20).filter(|b| *b > 0); + } + if adapter.device_type != wgpu::DeviceType::DiscreteGpu { + return None; + } + // Detected once per adapter, as `physical_memory` is: on Windows each detection builds a + // DXGI factory, and startup asks three times. Keyed by the adapter, since a GPU retry can + // pick another one. + /// (vendor, device) ids and the memory found for them. + type Seen = Vec<((u32, u32), Option)>; + static SEEN: std::sync::Mutex = std::sync::Mutex::new(Vec::new()); + let key = (adapter.vendor, adapter.device); + let mut seen = SEEN.lock().unwrap_or_else(std::sync::PoisonError::into_inner); + if let Some((_, bytes)) = seen.iter().find(|(k, _)| *k == key) { + return *bytes; + } + let bytes = detect_video_memory(key.0, key.1).filter(|b| *b > 0); + seen.push((key, bytes)); + bytes +} + +#[cfg(windows)] +fn detect_video_memory(vendor: u32, device: u32) -> Option { + use winsafe::prelude::*; + let factory = winsafe::CreateDXGIFactory1().ok()?; + factory + .EnumAdapters1() + .filter_map(|a| a.ok()?.GetDesc1().ok()) + .find(|d| d.VendorId == vendor && d.DeviceId == device) + .and_then(|d| u64::try_from(d.DedicatedVideoMemory).ok()) +} + +#[cfg(target_os = "linux")] +fn detect_video_memory(vendor: u32, device: u32) -> Option { + drm_video_memory(std::path::Path::new("/sys/class/drm"), vendor, device) +} + +/// amdgpu's VRAM size (`/device/mem_info_vram_total`, bytes) of the card under `drm` (the +/// sysfs `/sys/class/drm` folder) whose PCI `vendor` and `device` ids (hex files beside it) match. +#[cfg(any(target_os = "linux", test))] +fn drm_video_memory(drm: &std::path::Path, vendor: u32, device: u32) -> Option { + let hex = |p: std::path::PathBuf| u32::from_str_radix(std::fs::read_to_string(p).ok()?.trim().trim_start_matches("0x"), 16).ok(); + std::fs::read_dir(drm).ok()?.flatten().find_map(|card| { + let dev = card.path().join("device"); + if hex(dev.join("vendor"))? != vendor || hex(dev.join("device"))? != device { + return None; + } + std::fs::read_to_string(dev.join("mem_info_vram_total")).ok()?.trim().parse().ok() + }) +} + +#[cfg(not(any(target_os = "linux", windows)))] +fn detect_video_memory(_vendor: u32, _device: u32) -> Option { + None +} + struct Resources { view_bgl: wgpu::BindGroupLayout, tile_bgl: wgpu::BindGroupLayout, @@ -1318,7 +1415,7 @@ struct Resources { compositor_cache_budget: Option, compositor_focus: Option, /// GPU bytes one document's display texture set may take (see [`canvas_budget`]). - canvas_budget: u64, + canvas_budget: std::sync::Arc, encode_bgl: wgpu::BindGroupLayout, encode_pipeline: wgpu::RenderPipeline, lut_bgl: wgpu::BindGroupLayout, @@ -1584,7 +1681,7 @@ impl Resources { compositor_budget: None, compositor_cache_budget: None, compositor_focus: None, - canvas_budget: canvas_budget(physical_memory()), + canvas_budget: std::sync::Arc::new(std::sync::atomic::AtomicU64::new(canvas_budget(physical_memory(), None))), encode_bgl, encode_pipeline, } @@ -1623,7 +1720,7 @@ impl DocTextures { /// The texture set for a `size` canvas, or `None` (nothing created) when it doesn't fit /// the canvas budget. fn new(device: &wgpu::Device, res: &Resources, size: [u32; 2], tile: u32, format: wgpu::TextureFormat) -> Option { - if canvas_texture_bytes(size, tile, texel_bytes(format)) > res.canvas_budget { + if canvas_texture_bytes(size, tile, texel_bytes(format)) > res.canvas_budget.load(Ordering::Relaxed) { return None; } let mut tiles = Vec::new(); @@ -2241,15 +2338,72 @@ mod tests { fn gpu_budget_follows_memory_usage_and_ram() { const G: u64 = 1 << 30; // What Memory Usage leaves after the document, within a quarter of RAM. - assert_eq!(memory_budget(8 * G, 3 * G, Some(48 * G)), 5 * G); - assert_eq!(memory_budget(8 * G, 3 * G, Some(16 * G)), 4 * G); - assert_eq!(memory_budget(64 * G, 3 * G, Some(16 * G)), 4 * G); + assert_eq!(memory_budget(8 * G, 3 * G, Some(48 * G), None), 5 * G); + assert_eq!(memory_budget(8 * G, 3 * G, Some(16 * G), None), 4 * G); + assert_eq!(memory_budget(64 * G, 3 * G, Some(16 * G), None), 4 * G); // Never below the floor, even with nothing left; 16 GB assumed when RAM is unknown. - assert_eq!(memory_budget(8 * G, 10 * G, Some(48 * G)), MIN_GPU_BUDGET); - assert_eq!(memory_budget(64 * G, 0, None), 4 * G); + assert_eq!(memory_budget(8 * G, 10 * G, Some(48 * G), None), MIN_GPU_BUDGET); + assert_eq!(memory_budget(64 * G, 0, None, None), 4 * G); assert!(physical_memory().is_none_or(|b| b >= 256 << 20)); } + /// An 8 GB card in a 64 GB machine: both budgets used to plan for 16 GB, twice the card (#4536). + #[test] + fn gpu_budgets_fit_a_discrete_gpus_own_memory() { + const G: u64 = 1 << 30; + let (ram, vram) = (Some(64 * G), Some(8 * G)); + assert_eq!(memory_budget(64 * G, 3 * G, ram, vram), 3 * G); + assert_eq!(canvas_budget(ram, vram), 3 * G); + assert!(memory_budget(64 * G, 0, ram, vram) + canvas_budget(ram, vram) <= 6 * G, "a quarter of the card stays free"); + // A card larger than a quarter of RAM changes nothing; a tiny one keeps the floor. + assert_eq!(memory_budget(8 * G, 3 * G, Some(48 * G), Some(24 * G)), 5 * G); + assert_eq!(canvas_budget(Some(16 * G), Some(24 * G)), 4 * G); + assert_eq!(memory_budget(64 * G, 0, ram, Some(G)), MIN_GPU_BUDGET); + assert_eq!(canvas_budget(ram, Some(G)), MIN_GPU_BUDGET); + } + + /// DXGI reports each adapter's dedicated memory, found by the vendor and device ids wgpu gives. + #[cfg(windows)] + #[test] + fn windows_reports_the_adapters_own_memory() { + use winsafe::prelude::*; + let descs: Vec<_> = winsafe::CreateDXGIFactory1().unwrap().EnumAdapters1().map(|a| a.unwrap().GetDesc1().unwrap()).collect(); + assert!(!descs.is_empty(), "DXGI lists at least its software adapter"); + for d in descs.iter().filter(|d| d.DedicatedVideoMemory > 0) { + // The first adapter with these ids is the one matched. + let first = descs.iter().find(|e| e.VendorId == d.VendorId && e.DeviceId == d.DeviceId).unwrap(); + assert_eq!(detect_video_memory(d.VendorId, d.DeviceId), Some(first.DedicatedVideoMemory as u64)); + } + assert_eq!(detect_video_memory(0xdead, 0xbeef), None, "no such adapter"); + } + + /// The Linux reader finds the card by its PCI ids in a `/sys/class/drm`-shaped folder and reads + /// amdgpu's VRAM size; a card without one, unreadable ids or a malformed size give nothing. + #[test] + fn drm_video_memory_reads_the_matching_cards_vram() { + let drm = std::env::temp_dir().join(format!("pc-drm-{}", std::process::id())); + let card = |name: &str, vendor: &str, device: &str, vram: Option<&str>| { + let dev = drm.join(name).join("device"); + std::fs::create_dir_all(&dev).unwrap(); + std::fs::write(dev.join("vendor"), vendor).unwrap(); + std::fs::write(dev.join("device"), device).unwrap(); + if let Some(v) = vram { + std::fs::write(dev.join("mem_info_vram_total"), v).unwrap(); + } + }; + // An Intel iGPU (no VRAM file), an AMD card, and an entry with garbage ids. + card("card0", "0x8086\n", "0x9a49\n", None); + card("card1", "0x1002\n", "0x73bf\n", Some("17163091968\n")); + card("card2", "not hex\n", "0x73bf\n", Some("1\n")); + card("card3", "0x10de\n", "0x2684\n", Some("lots\n")); + assert_eq!(drm_video_memory(&drm, 0x1002, 0x73bf), Some(17_163_091_968)); + assert_eq!(drm_video_memory(&drm, 0x8086, 0x9a49), None, "no amdgpu VRAM file"); + assert_eq!(drm_video_memory(&drm, 0x10de, 0x2684), None, "a malformed size"); + assert_eq!(drm_video_memory(&drm, 0xdead, 0xbeef), None, "no such card"); + assert_eq!(drm_video_memory(&drm.join("missing"), 0x1002, 0x73bf), None, "no drm folder"); + std::fs::remove_dir_all(&drm).unwrap(); + } + /// Windows used to report no RAM at all, so the budget assumed 16 GB on every machine. #[cfg(windows)] #[test] @@ -2280,12 +2434,12 @@ mod tests { assert_eq!(canvas_texture_bytes([0, 500], 64, 4), 0); // A canvas declared in a few bytes of a file is priced without building (or looping over) it. let huge = canvas_texture_bytes([300_000, 300_000], 8192, 4); - assert!(huge > 300_000u64 * 300_000 * 4 && huge > canvas_budget(Some(1 << 40))); + assert!(huge > 300_000u64 * 300_000 * 4 && huge > canvas_budget(Some(1 << 40), None)); assert_eq!(canvas_texture_bytes([u32::MAX, u32::MAX], 64, 8), u64::MAX, "saturates"); // A quarter of RAM (16 GB assumed), never below the compositor's floor. - assert_eq!(canvas_budget(None), 4 << 30); - assert_eq!(canvas_budget(Some(1 << 30)), MIN_GPU_BUDGET); - assert!(canvas_texture_bytes([12_000, 8_000], DEFAULT_TILE, 8) < canvas_budget(None), "a 96 MP 16-bit canvas stays on the GPU"); + assert_eq!(canvas_budget(None, None), 4 << 30); + assert_eq!(canvas_budget(Some(1 << 30), None), MIN_GPU_BUDGET); + assert!(canvas_texture_bytes([12_000, 8_000], DEFAULT_TILE, 8) < canvas_budget(None, None), "a 96 MP 16-bit canvas stays on the GPU"); } #[test] diff --git a/crates/ui-egui/tests/canvas_16f.rs b/crates/ui-egui/tests/canvas_16f.rs index 068de36c49..279a6dac6e 100644 --- a/crates/ui-egui/tests/canvas_16f.rs +++ b/crates/ui-egui/tests/canvas_16f.rs @@ -259,3 +259,24 @@ fn thirty_two_bit_preview_exposes_values_above_one() { eprintln!("shown {got}, want {want:.1} (clipped would be {clipped:.1})"); assert!((got - want).abs() <= 3.0, "shown {got}, want {want:.1} (clipped {clipped:.1})"); } + +/// A 16-bit canvas whose float textures don't fit the canvas budget (a small graphics card, +/// #4536) stays on the GPU as `Rgba8Unorm` instead of falling back to the CPU canvas. +#[test] +fn sixteen_bit_canvas_too_big_for_floats_stays_on_the_gpu_in_8_bit() { + let _gpu = gpu_lock(); + let Some((g, _rs)) = canvas() else { return }; + let doc = gradient(SampleType::U16, 512, 256, 0.0, 1.0); + let size = [512, 256]; + let floats = gpu_canvas::canvas_texture_bytes(size, gpu_canvas::DEFAULT_TILE, 8); + assert_eq!(g.format_for(SampleType::U16, size), TextureFormat::Rgba16Float, "fits by default"); + // Room for the 8-bit set, not the float one. + g.set_canvas_budget(floats - 1); + assert_eq!(g.format_for(SampleType::U16, size), TextureFormat::Rgba8Unorm); + assert!(g.fits_canvas(&doc)); + assert_ne!(g.refresh(doc.id.0, &doc, None, None).kind, gpu_canvas::REFRESH_OVER_BUDGET); + assert_eq!(g.texture_info(doc.id.0).map(|(f, _)| f), Some(TextureFormat::Rgba8Unorm)); + g.set_canvas_budget(floats); + assert_eq!(g.format_for(SampleType::U16, size), TextureFormat::Rgba16Float); + g.release(); +} From 6d3443c09810ac2244f921154f0b8d8b74cafe54 Mon Sep 17 00:00:00 2001 From: Tanish Manku Date: Thu, 15 Oct 2026 21:22:46 -0700 Subject: [PATCH 43/47] GPU memory: discrete cards aren't charged for the document; GPU Memory preference; GPU Memory Full status (#5973) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * GPU memory: a graphics card's own memory isn't charged for the document; GPU Memory preference; GPU Memory Full in the status bar Many layers hit a cliff: past what the GPU memory budget leaves for layer pages, every edit that redraws the whole document (opacity, visibility, a nudge) goes to the CPU compositor. Measured on 3000x2000 full-canvas layers (RTX 5090, 31 GB RAM, Memory Usage 8 GB): 150 layers took 2.6 s per such edit, 300 layers 6 s. The budget was Memory Usage minus the document's pixels and History, which is right where the GPU shares system memory, but a discrete card's memory doesn't hold the document, so 150 layers of 6 MP (3.6 GB) squeezed the budget below what they needed. - Automatic budget: discrete adapters are no longer charged for the document's pixels (integrated and software adapters still are). 150 layers: 2.6 s -> 25 ms. - Preferences › Performance › GPU Memory (MB, 0 = Automatic; performance.gpuMemoryMb) sets the budget outright. 300 layers with 12 GB: 6 s -> 45-72 ms. - The status bar shows "GPU Memory Full" while the active document's layers don't fit (both status bar styles); hovering gives the numbers, clicking opens Preferences › Performance. Its Graphics rows show the budget and what the layers need. The slow CPU redraw notice gives the numbers and points at GPU Memory. - Headless: the preference is a normal pref (prefs.set), and ui.state's perf.timings reports gpuLayerFit {doc, needed, budget} next to gpuBudgetMb. Next (owner-approved plan): after #5370, read the card's real limit from the driver (wgpu memory budget thresholds) and shrink on out-of-memory; then redraw the visible area on the GPU first when layers still don't fit. * GPU memory: typed discrete check; the status and budget follow a GPU Memory change at once (measure_fit); perf rows P60/P61 for whole-document edits among 150 full-canvas layers under the app's budget * GPU memory on top of #5234: memory_budget takes the card's memory (vram) and the GPU Memory value; the document's pixels are charged unless the card's own memory is known; a set GPU Memory is capped at the card's share; benches use video_memory --- crates/engine/src/prefs.rs | 6 ++ crates/gpu/src/lib.rs | 5 + crates/ui-egui/examples/huge_doc_bench.rs | 2 +- crates/ui-egui/examples/perf_scenarios.rs | 67 +++++++++++- crates/ui-egui/src/canvas.rs | 33 ++++-- crates/ui-egui/src/chrome_ui.rs | 51 ++++++++++ crates/ui-egui/src/gpu_canvas.rs | 118 +++++++++++++++++----- crates/ui-egui/src/i18n/cs.tsv | 10 +- crates/ui-egui/src/i18n/de.tsv | 10 +- crates/ui-egui/src/i18n/el.tsv | 10 +- crates/ui-egui/src/i18n/es.tsv | 10 +- crates/ui-egui/src/i18n/fr.tsv | 10 +- crates/ui-egui/src/i18n/hu.tsv | 10 +- crates/ui-egui/src/i18n/id.tsv | 10 +- crates/ui-egui/src/i18n/it.tsv | 10 +- crates/ui-egui/src/i18n/ja.tsv | 10 +- crates/ui-egui/src/i18n/ko.tsv | 10 +- crates/ui-egui/src/i18n/nl.tsv | 10 +- crates/ui-egui/src/i18n/pl.tsv | 10 +- crates/ui-egui/src/i18n/pt-br.tsv | 10 +- crates/ui-egui/src/i18n/ru.tsv | 10 +- crates/ui-egui/src/i18n/sr.tsv | 10 +- crates/ui-egui/src/i18n/tr.tsv | 10 +- crates/ui-egui/src/i18n/uk.tsv | 10 +- crates/ui-egui/src/i18n/vi.tsv | 10 +- crates/ui-egui/src/i18n/zh-hans.tsv | 10 +- crates/ui-egui/src/i18n/zh-hant.tsv | 10 +- crates/ui-egui/src/notices.rs | 13 ++- crates/ui-egui/src/panels.rs | 2 + crates/ui-egui/src/prefs_ui.rs | 29 +++++- docs/scorecard.md | 8 +- perf/budgets.toml | 18 ++++ 32 files changed, 491 insertions(+), 61 deletions(-) diff --git a/crates/engine/src/prefs.rs b/crates/engine/src/prefs.rs index beccc97b49..2c2507226d 100644 --- a/crates/engine/src/prefs.rs +++ b/crates/engine/src/prefs.rs @@ -538,6 +538,10 @@ pub struct Performance { pub low_latency_drawing: bool, /// Linux display server (applies at next launch; see [`LinuxDisplayServer`]). pub linux_display_server: LinuxDisplayServer, + /// GPU memory the canvas compositor may hold for layer pages and effect maps, in MB; 0 is + /// automatic (a graphics card's own memory isn't charged for the document's pixels, memory + /// shared with the system is). Layers that don't fit redraw the whole document on the CPU. + pub gpu_memory_mb: u32, /// Memory budget of the layer-effect cache, in MB. pub effect_cache_mb: u32, pub legacy_compositing: bool, @@ -569,6 +573,7 @@ impl Default for Performance { low_resolution_previews: true, low_latency_drawing: false, linux_display_server: LinuxDisplayServer::Auto, + gpu_memory_mb: 0, effect_cache_mb: 768, legacy_compositing: false, } @@ -1107,6 +1112,7 @@ pub fn range(path: &str) -> Option<(f64, f64)> { "interface.notificationDurationSeconds" => (1.0, 120.0), "export.jpegQuality" | "export.webpQuality" => (1.0, 100.0), "performance.memoryUsageMb" => (256.0, 1_048_576.0), + "performance.gpuMemoryMb" => (0.0, 1_048_576.0), "performance.historyStates" => (1.0, 1000.0), "performance.cacheLevels" => (1.0, 8.0), "performance.cacheTileSize" => (256.0, 16384.0), diff --git a/crates/gpu/src/lib.rs b/crates/gpu/src/lib.rs index 8f8839b9bd..690d144d68 100644 --- a/crates/gpu/src/lib.rs +++ b/crates/gpu/src/lib.rs @@ -794,6 +794,11 @@ impl Compositor { total } + /// The GPU memory resident layer pages may hold (bytes; see [`Compositor::set_memory_budget`]). + pub fn resident_budget(&self) -> u64 { + self.resident_budget + } + /// Whether a render of `region` keeps every layer page it needs within the budget; when it /// doesn't, each render re-uploads what was evicted. pub fn fits_budget(&self, doc: &Document, region: Rect) -> bool { diff --git a/crates/ui-egui/examples/huge_doc_bench.rs b/crates/ui-egui/examples/huge_doc_bench.rs index 6b0d03cda2..3ddbc5afab 100644 --- a/crates/ui-egui/examples/huge_doc_bench.rs +++ b/crates/ui-egui/examples/huge_doc_bench.rs @@ -201,7 +201,7 @@ fn main() { .sum(); let ram = photocraft_ui_egui::gpu_canvas::physical_memory(); let vram = g.video_memory(); - let budget = photocraft_ui_egui::gpu_canvas::memory_budget(allowance, pixels, ram, vram); + let budget = photocraft_ui_egui::gpu_canvas::memory_budget(allowance, pixels, ram, vram, 0); g.set_memory_budget(budget); println!( "RAM {} MB, VRAM {} MB, document pixels {} MB, Memory Usage {} MB -> GPU budget {} MB", diff --git a/crates/ui-egui/examples/perf_scenarios.rs b/crates/ui-egui/examples/perf_scenarios.rs index 1b71258473..969d9533c1 100644 --- a/crates/ui-egui/examples/perf_scenarios.rs +++ b/crates/ui-egui/examples/perf_scenarios.rs @@ -242,6 +242,8 @@ struct Sizes { big_layer: (u32, u32), /// Layers in the "150-layer document". many_layers: usize, + /// The full-canvas layers document (L3): size and layer count. + full_layers: (u32, u32, usize), /// The 16-bit document (15000 × 10000). huge16: (u32, u32), /// The 32-bit document with 20 layers. @@ -258,6 +260,7 @@ const FULL: Sizes = Sizes { layered: (5472, 3648), big_layer: (4243, 2828), many_layers: 150, + full_layers: (3000, 2000, 150), huge16: (15000, 10000), float32: (4000, 3000), ten_mp: (3872, 2592), @@ -269,6 +272,7 @@ const QUICK: Sizes = Sizes { layered: (1600, 1066), big_layer: (1200, 800), many_layers: 40, + full_layers: (800, 600, 20), huge16: (2400, 1600), float32: (1200, 900), ten_mp: (1200, 800), @@ -357,9 +361,10 @@ fn main() { // A panic in one group (a crash the scenario found) is reported against the row that was // running, and the other groups still run. type Group = fn(&mut Bench, &Sizes); - let groups: [(&str, Group); 5] = [ + let groups: [(&str, Group); 6] = [ ("layered", layered_scenarios), ("many layers", many_layer_scenarios), + ("full-canvas layers", full_canvas_layer_scenarios), ("bit depths", depth_scenarios), ("long operations", job_scenarios), ("algorithms", algorithm_scenarios), @@ -539,6 +544,66 @@ fn layered_scenarios(b: &mut Bench, sz: &Sizes) { } /// #209: arrow-key nudge in the 150-layer document; #1771: a new layer there. +/// Layers overhaul L3: edits that redraw the whole document (a layer's opacity, hiding it) with +/// many full-canvas layers, under the app's own GPU memory budget (Memory Usage at its default, +/// the adapter's kind; `gpu_canvas::memory_budget`). Past what the budget holds, each one went to +/// the CPU compositor (2.6 s at 150 layers of 6 MP on a discrete card). +fn full_canvas_layer_scenarios(b: &mut Bench, sz: &Sizes) { + let opacity = "opacity of a layer among many full-canvas layers + refresh"; + let hide = "hide a layer among many full-canvas layers + refresh"; + if !b.wanted(opacity) && !b.wanted(hide) { + return; + } + b.current = Some(opacity.to_string()); + let (w, h, n) = sz.full_layers; + let mut s = Session::new(); + let built = (|| -> Res { + exec(&mut s, "file.new", json!({"width": w, "height": h, "background": "white"}))?; + let mut mid = 0; + for i in 0..n as u32 { + exec(&mut s, "layer.new.layer", json!({"name": format!("full {i}")}))?; + exec(&mut s, "edit.fill", json!({"color": format!("#{:02x}{:02x}80", (i * 37) % 256, (i * 91) % 256), "opacity": 3}))?; + if i % 7 == 3 { + exec(&mut s, "layer.layerStyle.dropShadow", json!({"distance": 6, "size": 8}))?; + } + if i as usize == n / 2 { + mid = active_layer(&s)?; + } + } + Ok(mid) + })(); + let mid = match built { + Ok(v) => v, + Err(e) => { + b.errors.push((opacity.to_string(), e)); + return; + } + }; + if let (Some((g, _)), Some(st)) = (&b.gpu, s.active()) { + let pixels = st.history.pixel_bytes(&st.doc) as u64; + let allowance = u64::from(photocraft_engine::prefs::Performance::default().memory_usage_mb) << 20; + g.set_memory_budget(photocraft_ui_egui::gpu_canvas::memory_budget( + allowance, + pixels, + photocraft_ui_egui::gpu_canvas::physical_memory(), + g.video_memory(), + 0, + )); + } + let _ = b.refresh(&s, true); + let reps = b.reps; + b.time(opacity, &mut s, reps, true, |b, s, i| { + let t = Instant::now(); + exec(s, "layer.setProps", json!({"layer": mid, "opacity": if i % 2 == 0 { 0.5 } else { 1.0 }}))?; + Ok(ms(t) + b.refresh(s, false)?) + }); + b.time(hide, &mut s, reps, true, |b, s, i| { + let t = Instant::now(); + exec(s, "layer.setProps", json!({"layer": mid, "visible": i % 2 == 1}))?; + Ok(ms(t) + b.refresh(s, false)?) + }); +} + fn many_layer_scenarios(b: &mut Bench, sz: &Sizes) { // Row names stay stable across modes: the layer count is in the mode, not the key. let name = "nudge 1 px in the many-layer document + refresh".to_string(); diff --git a/crates/ui-egui/src/canvas.rs b/crates/ui-egui/src/canvas.rs index a85c25c3c4..1ee7f02db9 100644 --- a/crates/ui-egui/src/canvas.rs +++ b/crates/ui-egui/src/canvas.rs @@ -1615,7 +1615,13 @@ fn ensure_gpu(app: &mut PhotocraftApp, ctx: &egui::Context, idx: usize, visible: grid: None, }); let present = cache.on_gpu && gpu.has(id.0, size); - if present && cache.revision == revision && cache.preview_key == preview_key { + let current = cache.revision == revision && cache.preview_key == preview_key; + // GPU Memory or Memory Usage changed with nothing to redraw: the new budget, and whether the + // layers fit it, show at once (the status bar's GPU Memory Full). + if present && app.perf.gpu_budget_allowance != budget_allowance(app) { + gpu_budget(app, &gpu, idx, false, visible); + } + if present && current { // An adjustment preview composited where the view was: catch up where it moved to. if let Some(r) = crate::adjust_preview::uncovered(app, id, raw_key, visible) { let r = gpu.refresh(id.0, &doc, Some(r), display.as_deref()); @@ -1633,6 +1639,7 @@ fn ensure_gpu(app: &mut PhotocraftApp, ctx: &egui::Context, idx: usize, visible: gpu_budget(app, &gpu, idx, partial, visible); let defer = crate::background_redraw::enabled(app); let r = gpu.refresh_with(id.0, &doc, if partial { damage } else { None }, display.as_deref(), defer); + app.perf.gpu_layer_fit = gpu.layer_fit(); if r.kind == "lost" || r.kind == crate::gpu_canvas::REFRESH_OVER_BUDGET { // The device is gone (`gpu_status` then drops the GPU canvas) or the canvas is over the // texture budget: draw this frame on the CPU path. @@ -1673,22 +1680,32 @@ fn ensure_gpu(app: &mut PhotocraftApp, ctx: &egui::Context, idx: usize, visible: true } -/// Point the wgpu compositor at what the view shows, and size its memory budget from Memory -/// Usage, the document's pixels and History, physical memory and the GPU's own: on full refreshes -/// (pixels counted once per structural change, not per brush dab) and whenever the preference -/// changes. +/// Point the wgpu compositor at what the view shows, and size its memory budget from GPU Memory, +/// or Memory Usage, the document's pixels and History (charged where the GPU shares the system's +/// memory), physical memory and the GPU's own: on full refreshes (pixels counted once per +/// structural change, not per brush dab) and whenever either preference changes. fn gpu_budget(app: &mut PhotocraftApp, gpu: &crate::gpu_canvas::GpuCanvas, idx: usize, partial: bool, visible: DRect) { gpu.set_focus(Some(visible)); - let allowance = u64::from(app.session.prefs().performance.memory_usage_mb).saturating_mul(1 << 20); - if partial && app.perf.gpu_budget_allowance == allowance && gpu.memory_budget().is_some() { + let allowance = budget_allowance(app); + let changed = app.perf.gpu_budget_allowance != allowance; + if partial && !changed && gpu.memory_budget().is_some() { return; } let Some(st) = app.session.documents().get(idx) else { return }; let pixels = st.memory_bytes() as u64; - let budget = crate::gpu_canvas::memory_budget(allowance, pixels, crate::gpu_canvas::physical_memory(), gpu.video_memory()); + let budget = crate::gpu_canvas::memory_budget(allowance.0, pixels, crate::gpu_canvas::physical_memory(), gpu.video_memory(), allowance.1); gpu.set_memory_budget(budget); app.perf.gpu_budget_mb = budget >> 20; app.perf.gpu_budget_allowance = allowance; + if changed && let Some(fit) = gpu.measure_fit(&st.doc) { + app.perf.gpu_layer_fit = Some(fit); + } +} + +/// Memory Usage and GPU Memory (Preferences › Performance), in bytes. +fn budget_allowance(app: &PhotocraftApp) -> (u64, u64) { + let performance = &app.session.prefs().performance; + (u64::from(performance.memory_usage_mb).saturating_mul(1 << 20), u64::from(performance.gpu_memory_mb).saturating_mul(1 << 20)) } /// Live preview for an open filter dialog: run the filter on the proxy and upload it. With diff --git a/crates/ui-egui/src/chrome_ui.rs b/crates/ui-egui/src/chrome_ui.rs index bbdce8fa78..02fa202cc5 100644 --- a/crates/ui-egui/src/chrome_ui.rs +++ b/crates/ui-egui/src/chrome_ui.rs @@ -183,6 +183,24 @@ pub fn status_bar_pro(app: &mut PhotocraftApp, ui: &mut egui::Ui) { status_message(app, ui, &t); } +/// The active document's layers don't fit the GPU memory budget (`perf.gpuLayerFit`): edits that +/// redraw the whole document do it on the CPU, so say so in the status bar until they fit again. +/// Clicking it opens Preferences › Performance, where GPU Memory raises the budget. +pub fn gpu_memory_status(app: &mut PhotocraftApp, ui: &mut egui::Ui) { + let Some(doc) = app.session.active().map(|st| st.doc.id.0) else { return }; + let Some(fit) = app.perf.gpu_layer_fit.filter(|f| f.doc == doc && !f.fits()) else { return }; + let t = Tokens::get(ui.ctx()); + let resp = ui + .add(egui::Label::new(RichText::new(tl!("GPU Memory Full")).color(t.warning).size(12.0)).sense(Sense::click())) + .on_hover_text(crate::i18n::fmt( + tl!("The layers need {needed} of GPU memory and may hold {budget}, so edits that redraw the whole document do it on the CPU. Click to set GPU Memory."), + &[("needed", &fmt_bytes(fit.needed)), ("budget", &fmt_bytes(fit.budget))], + )); + if resp.clicked() { + crate::shortcut_dispatch::dispatch(app, ui.ctx(), "edit.preferences.performance"); + } +} + /// The latest status message after a separator, in the warning colour when it is an error. fn status_message(app: &PhotocraftApp, ui: &mut egui::Ui, t: &Tokens) { if app.ui.status.is_empty() { @@ -293,6 +311,39 @@ mod tests { assert_eq!(status_info_text_with_units(&d, "dimensions", "", "", &units), "722.7 pt x 451.7 pt (240 ppi)"); } + /// L3: layers over the GPU memory budget show "GPU Memory Full" in the status bar, for that + /// document only, and clicking it opens Preferences › Performance (GPU Memory). + #[test] + fn status_bar_says_when_layers_dont_fit_gpu_memory() { + use egui_kittest::{Harness, kittest::Queryable}; + for theme in [crate::theme::ThemeKind::Pro, crate::theme::ThemeKind::Studio] { + let mut h = Harness::builder().with_size(vec2(1200.0, 700.0)).with_max_steps(64).build_eframe(move |cc| { + PhotocraftApp::setup_context(&cc.egui_ctx, theme); + let mut app = PhotocraftApp::new(photocraft_engine::Session::new(), crate::Services::default()); + app.ui.theme = theme; + app + }); + h.state_mut().run("file.new", json!({"width": 800, "height": 600})).unwrap(); + h.state_mut().sync_views(); + h.run_steps(4); + let doc = h.state().session.active().unwrap().doc.id.0; + assert!(h.query_by_label("GPU Memory Full").is_none(), "{theme:?}: nothing measured yet"); + let over = crate::gpu_canvas::LayerFit { doc, needed: 3 << 30, budget: 2 << 30 }; + h.state_mut().perf.gpu_layer_fit = Some(crate::gpu_canvas::LayerFit { doc: doc + 1, ..over }); + h.run_steps(2); + assert!(h.query_by_label("GPU Memory Full").is_none(), "{theme:?}: another document's layers"); + h.state_mut().perf.gpu_layer_fit = Some(crate::gpu_canvas::LayerFit { budget: 4 << 30, ..over }); + h.run_steps(2); + assert!(h.query_by_label("GPU Memory Full").is_none(), "{theme:?}: they fit"); + h.state_mut().perf.gpu_layer_fit = Some(over); + h.run_steps(2); + h.get_by_label("GPU Memory Full").click(); + h.run_steps(4); + let open = h.state().ui.dialogs.iter().any(|d| d.fields.get("section") == Some(&json!("performance"))); + assert!(open, "{theme:?}: Preferences › Performance opens"); + } + } + #[test] fn status_bar_updates_dimensions_after_a_ruler_preference_change() { use egui_kittest::{Harness, kittest::Queryable}; diff --git a/crates/ui-egui/src/gpu_canvas.rs b/crates/ui-egui/src/gpu_canvas.rs index e53e89888f..8ef3973305 100644 --- a/crates/ui-egui/src/gpu_canvas.rs +++ b/crates/ui-egui/src/gpu_canvas.rs @@ -303,6 +303,28 @@ impl GpuCanvas { Some((c.resident_bytes(), c.fx_cache_bytes().saturating_add(usize::try_from(c.composite_cache_bytes()).unwrap_or(usize::MAX)))) } + /// Whether `doc`'s layers fit the budget as it now stands, measured without a refresh (a GPU + /// Memory change shows in the status bar at once). `None` before the compositor exists. + pub fn measure_fit(&self, doc: &photocraft_doc::Document) -> Option { + let mut r = self.rs.renderer.write(); + let res = r.callback_resources.get_mut::()?; + let budget = res.compositor_budget; + let comp = res.compositor.as_mut()?; + if let Some(b) = budget { + comp.set_memory_budget(b); + } + let fit = LayerFit { doc: doc.id.0, needed: comp.working_set(doc, doc.bounds()), budget: comp.resident_budget() }; + res.layer_fit = Some(fit); + Some(fit) + } + + /// Whether the layers of the last full (or large) refresh fit the GPU memory budget, and by + /// how much (`None` before the first one). + pub fn layer_fit(&self) -> Option { + let r = self.rs.renderer.read(); + r.callback_resources.get::()?.layer_fit + } + /// Set the checkerboard and gamut warning colours. pub fn set_style(&self, style: CanvasStyle) { let mut r = self.rs.renderer.write(); @@ -471,9 +493,13 @@ impl GpuCanvas { // compositor does it (in the background on desktop). So does a large damage rect, such // as hiding a big layer (#4024); small edits in the view stay on the GPU. let large = region.width() as u64 * region.height() as u64 >= DEFER_PX; - if (region == doc.bounds() || large) && !comp.fits_budget(doc, region) { - res.compositor = Some(comp); - return Err(photocraft_gpu::Unsupported(OVER_BUDGET.into())); + if region == doc.bounds() || large { + let fit = LayerFit { doc: doc.id.0, needed: comp.working_set(doc, region), budget: comp.resident_budget() }; + res.layer_fit = Some(fit); + if !fit.fits() { + res.compositor = Some(comp); + return Err(photocraft_gpu::Unsupported(OVER_BUDGET.into())); + } } if fresh { let Some(tex) = DocTextures::new(device, res, size, self.tile, format) else { @@ -926,9 +952,13 @@ pub struct Perf { /// GPU memory the compositor may hold for layer pages and effect maps (MB; see /// [`memory_budget`]). pub gpu_budget_mb: u64, - /// The Memory Usage allowance (bytes) `gpu_budget_mb` was computed from. + /// The Memory Usage allowance and the GPU Memory preference (bytes) `gpu_budget_mb` was + /// computed from. #[serde(skip)] - pub gpu_budget_allowance: u64, + pub gpu_budget_allowance: (u64, u64), + /// The layer pages the last full refresh needed against the part of the budget they may + /// hold; when they don't fit, edits that redraw the whole document do it on the CPU. + pub gpu_layer_fit: Option, /// Last command run through the app and its duration (ms). pub last_command: String, pub command_ms: f64, @@ -1212,22 +1242,45 @@ fn texel_to_f32(format: wgpu::TextureFormat, b: &[u8]) -> [f32; 4] { // --------------------------------------------------------------------------------------------- // GPU resources +/// The GPU memory a refresh's layer pages need against what they may hold (bytes). +#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, serde::Serialize)] +#[serde(rename_all = "camelCase")] +pub struct LayerFit { + /// The document refreshed. + pub doc: u64, + pub needed: u64, + pub budget: u64, +} + +impl LayerFit { + pub fn fits(&self) -> bool { + self.needed <= self.budget + } +} + /// Floor of the compositor's memory budget: enough for a viewport's pages of a dozen layers. pub const MIN_GPU_BUDGET: u64 = 512 << 20; /// Why a full refresh whose layer pages don't fit [`memory_budget`] goes to the CPU compositor. pub const OVER_BUDGET: &str = "layers exceed the GPU memory budget; full refresh on the CPU"; -/// GPU memory the wgpu compositor may hold for layer pages and effect maps: what Memory Usage -/// (`allowance`, Preferences › Performance) leaves after the document's pixels and History -/// (`pixels`), at most a quarter of physical memory (`ram`; 16 GB assumed when unknown) and -/// [`vram_share`] of a discrete GPU's own memory (`vram`, from [`video_memory`]), and at least -/// [`MIN_GPU_BUDGET`]. On unified-memory machines these textures share RAM with the document, so -/// a huge document keeps its layers on the GPU only as far as memory allows; the rest is -/// uploaded per refresh as the view needs it. -pub fn memory_budget(allowance: u64, pixels: u64, ram: Option, vram: Option) -> u64 { +/// GPU memory the wgpu compositor may hold for layer pages and effect maps. +/// +/// `fixed` (Preferences › Performance › GPU Memory, bytes) sets it when it isn't 0, within +/// [`vram_share`] of a discrete GPU's own memory (`vram`, from [`video_memory`]). Otherwise it is +/// Memory Usage (`allowance`) within a quarter of physical memory (`ram`; 16 GB assumed when +/// unknown) and the VRAM share. The document's pixels and History (`pixels`) come off Memory Usage +/// unless the card's own memory is known: a discrete card doesn't hold them, while integrated and +/// software adapters (and a card whose memory can't be read) share the system's. Never below +/// [`MIN_GPU_BUDGET`]. Layers beyond it redraw full refreshes on the CPU ([`OVER_BUDGET`]). +pub fn memory_budget(allowance: u64, pixels: u64, ram: Option, vram: Option, fixed: u64) -> u64 { + let card = vram.map_or(u64::MAX, vram_share); + if fixed > 0 { + return fixed.min(card).max(MIN_GPU_BUDGET); + } let ram = ram.unwrap_or(16 << 30); - allowance.saturating_sub(pixels).min(ram / 4).min(vram.map_or(u64::MAX, vram_share)).max(MIN_GPU_BUDGET) + let charged = if vram.is_some() { 0 } else { pixels }; + allowance.saturating_sub(charged).min(ram / 4).min(card).max(MIN_GPU_BUDGET) } /// The share of a discrete GPU's memory each of [`memory_budget`] and [`canvas_budget`] may @@ -1412,6 +1465,9 @@ struct Resources { /// GPU memory the compositor may hold (`None`: its default), and the document area the /// view shows; applied before every composite. compositor_budget: Option, + /// The layer pages the last full (or large) refresh needed and the budget they had to fit + /// (bytes): when they don't fit, full refreshes go to the CPU ([`OVER_BUDGET`]). + layer_fit: Option, compositor_cache_budget: Option, compositor_focus: Option, /// GPU bytes one document's display texture set may take (see [`canvas_budget`]). @@ -1679,6 +1735,7 @@ impl Resources { compositor_failed: None, health: photocraft_gpu::DeviceHealth::new(), compositor_budget: None, + layer_fit: None, compositor_cache_budget: None, compositor_focus: None, canvas_budget: std::sync::Arc::new(std::sync::atomic::AtomicU64::new(canvas_budget(physical_memory(), None))), @@ -2337,13 +2394,23 @@ mod tests { #[test] fn gpu_budget_follows_memory_usage_and_ram() { const G: u64 = 1 << 30; - // What Memory Usage leaves after the document, within a quarter of RAM. - assert_eq!(memory_budget(8 * G, 3 * G, Some(48 * G), None), 5 * G); - assert_eq!(memory_budget(8 * G, 3 * G, Some(16 * G), None), 4 * G); - assert_eq!(memory_budget(64 * G, 3 * G, Some(16 * G), None), 4 * G); + // Card memory unknown (integrated, software, or unreadable): what Memory Usage leaves + // after the document, within a quarter of RAM. + assert_eq!(memory_budget(8 * G, 3 * G, Some(48 * G), None, 0), 5 * G); + assert_eq!(memory_budget(8 * G, 3 * G, Some(16 * G), None, 0), 4 * G); + assert_eq!(memory_budget(64 * G, 3 * G, Some(16 * G), None, 0), 4 * G); // Never below the floor, even with nothing left; 16 GB assumed when RAM is unknown. - assert_eq!(memory_budget(8 * G, 10 * G, Some(48 * G), None), MIN_GPU_BUDGET); - assert_eq!(memory_budget(64 * G, 0, None, None), 4 * G); + assert_eq!(memory_budget(8 * G, 10 * G, Some(48 * G), None, 0), MIN_GPU_BUDGET); + assert_eq!(memory_budget(64 * G, 0, None, None, 0), 4 * G); + // A card's own memory, known, doesn't hold the document's pixels: 10 GB of layers no + // longer squeeze the budget, which stays within 3/8 of the card (#4536). + assert_eq!(memory_budget(8 * G, 10 * G, Some(32 * G), Some(32 * G), 0), 8 * G); + assert_eq!(memory_budget(8 * G, 10 * G, Some(32 * G), Some(16 * G), 0), 6 * G); + assert_eq!(memory_budget(8 * G, 10 * G, Some(16 * G), Some(32 * G), 0), 4 * G); + // GPU Memory set: that much, within the card's share, above the floor. + assert_eq!(memory_budget(8 * G, 10 * G, Some(16 * G), None, 12 * G), 12 * G); + assert_eq!(memory_budget(8 * G, 10 * G, Some(16 * G), Some(16 * G), 12 * G), 6 * G); + assert_eq!(memory_budget(8 * G, 0, None, Some(32 * G), 1 << 20), MIN_GPU_BUDGET); assert!(physical_memory().is_none_or(|b| b >= 256 << 20)); } @@ -2352,13 +2419,14 @@ mod tests { fn gpu_budgets_fit_a_discrete_gpus_own_memory() { const G: u64 = 1 << 30; let (ram, vram) = (Some(64 * G), Some(8 * G)); - assert_eq!(memory_budget(64 * G, 3 * G, ram, vram), 3 * G); + assert_eq!(memory_budget(64 * G, 3 * G, ram, vram, 0), 3 * G); assert_eq!(canvas_budget(ram, vram), 3 * G); - assert!(memory_budget(64 * G, 0, ram, vram) + canvas_budget(ram, vram) <= 6 * G, "a quarter of the card stays free"); - // A card larger than a quarter of RAM changes nothing; a tiny one keeps the floor. - assert_eq!(memory_budget(8 * G, 3 * G, Some(48 * G), Some(24 * G)), 5 * G); + assert!(memory_budget(64 * G, 0, ram, vram, 0) + canvas_budget(ram, vram) <= 6 * G, "a quarter of the card stays free"); + // A card larger than a quarter of RAM caps nothing (its own memory isn't charged for the + // document's 3 GB); a tiny one keeps the floor. + assert_eq!(memory_budget(8 * G, 3 * G, Some(48 * G), Some(24 * G), 0), 8 * G); assert_eq!(canvas_budget(Some(16 * G), Some(24 * G)), 4 * G); - assert_eq!(memory_budget(64 * G, 0, ram, Some(G)), MIN_GPU_BUDGET); + assert_eq!(memory_budget(64 * G, 0, ram, Some(G), 0), MIN_GPU_BUDGET); assert_eq!(canvas_budget(ram, Some(G)), MIN_GPU_BUDGET); } diff --git a/crates/ui-egui/src/i18n/cs.tsv b/crates/ui-egui/src/i18n/cs.tsv index c73c032df7..e9cc357fc9 100644 --- a/crates/ui-egui/src/i18n/cs.tsv +++ b/crates/ui-egui/src/i18n/cs.tsv @@ -2442,7 +2442,6 @@ cameraRaw Color Barvy Redrawing Překreslování This redraw took {seconds} s on the CPU. Toto překreslení na CPU trvalo {seconds} s. Edits that change the whole document, such as a layer's blend mode, opacity or visibility, redraw all of it. Úpravy, které mění celý dokument, například režim prolnutí, krytí nebo viditelnost vrstvy, jej překreslí celý. - Its layers don't fit the GPU memory budget. Raising Memory usage in Preferences › Performance raises the budget, up to a quarter of this computer's memory. Vrstvy se nevejdou do rozpočtu paměti GPU. Zvýšením hodnoty Využití paměti v Předvolbách › Výkon se rozpočet zvětší, nejvýše na čtvrtinu paměti tohoto počítače. The GPU compositor wasn't used: {reason} Kompozitor GPU nebyl použit: {reason} fontWeight Thin Jemná fontWeight ExtraLight Extrajemná @@ -3808,3 +3807,12 @@ navigator Zoom Zvětšení # Free Transform: a new layer while the box is open (#5859) Free Transform is still being applied; try again when it has finished. Volná transformace se stále použije; zkuste to znovu, až skončí. The transform couldn't be applied. Transformaci nelze použít. +# Preferences › Performance › GPU Memory; the status bar and slow-redraw notice + Its layers need {needed} of GPU memory; the budget leaves them {budget}. Vrstvy potřebují {needed} paměti GPU; rozpočet jim ponechává {budget}. + Set GPU Memory in Preferences › Performance to what your graphics card can hold (Automatic follows Memory Usage, up to a quarter of this computer's memory). The status bar shows GPU Memory Full while they don't fit. V Předvolbách › Výkon nastavte Paměť GPU na tolik, kolik vaše grafická karta pojme (Automaticky se řídí Využitím paměti, nejvýše čtvrtinou paměti tohoto počítače). Dokud se nevejdou, stavový řádek ukazuje Paměť GPU je plná. + GPU Memory Full Paměť GPU je plná + The layers need {needed} of GPU memory and may hold {budget}, so edits that redraw the whole document do it on the CPU. Click to set GPU Memory. Vrstvy potřebují {needed} paměti GPU a mohou zabrat {budget}, takže úpravy, které překreslují celý dokument, to dělají na CPU. Kliknutím nastavíte Paměť GPU. + GPU memory budget: {budget} Rozpočet paměti GPU: {budget} + This document's layers: {needed} of {budget} for layers Vrstvy tohoto dokumentu: {needed} z {budget} pro vrstvy + This document's layers need {needed} but may hold {budget}: whole-document redraws use the CPU Vrstvy tohoto dokumentu potřebují {needed}, ale mohou zabrat {budget}: překreslení celého dokumentu probíhá na CPU + GPU Memory (MB, 0 = Automatic) Paměť GPU (MB, 0 = Automaticky) diff --git a/crates/ui-egui/src/i18n/de.tsv b/crates/ui-egui/src/i18n/de.tsv index bfa6d5f8f1..23374a2993 100644 --- a/crates/ui-egui/src/i18n/de.tsv +++ b/crates/ui-egui/src/i18n/de.tsv @@ -2481,7 +2481,6 @@ layerLabel No Color Keine Farbe Redrawing Neuzeichnen This redraw took {seconds} s on the CPU. Dieses Neuzeichnen auf der CPU dauerte {seconds} s. Edits that change the whole document, such as a layer's blend mode, opacity or visibility, redraw all of it. Änderungen, die das ganze Dokument betreffen, etwa Füllmethode, Deckkraft oder Sichtbarkeit einer Ebene, zeichnen es vollständig neu. - Its layers don't fit the GPU memory budget. Raising Memory usage in Preferences › Performance raises the budget, up to a quarter of this computer's memory. Die Ebenen passen nicht in das GPU-Speicherbudget. Eine höhere Speichernutzung unter Voreinstellungen › Leistung erhöht das Budget, bis zu einem Viertel des Arbeitsspeichers dieses Computers. The GPU compositor wasn't used: {reason} Der GPU-Compositor wurde nicht verwendet: {reason} cameraRaw Light Licht fontWeight Thin Haarfein @@ -3202,3 +3201,12 @@ curves Smooth Glätten # Free Transform: a new layer while the box is open (#5859) Free Transform is still being applied; try again when it has finished. Frei transformieren wird noch angewendet; versuchen Sie es erneut, wenn es fertig ist. The transform couldn't be applied. Die Transformation konnte nicht angewendet werden. +# Preferences › Performance › GPU Memory; the status bar and slow-redraw notice + Its layers need {needed} of GPU memory; the budget leaves them {budget}. Die Ebenen brauchen {needed} GPU-Speicher; das Budget lässt ihnen {budget}. + Set GPU Memory in Preferences › Performance to what your graphics card can hold (Automatic follows Memory Usage, up to a quarter of this computer's memory). The status bar shows GPU Memory Full while they don't fit. Stellen Sie unter Voreinstellungen › Leistung den GPU-Speicher auf das ein, was Ihre Grafikkarte fassen kann (Automatisch folgt der Speichernutzung, bis zu einem Viertel des Arbeitsspeichers dieses Computers). Solange sie nicht hineinpassen, zeigt die Statusleiste GPU-Speicher voll. + GPU Memory Full GPU-Speicher voll + The layers need {needed} of GPU memory and may hold {budget}, so edits that redraw the whole document do it on the CPU. Click to set GPU Memory. Die Ebenen brauchen {needed} GPU-Speicher und dürfen {budget} belegen, daher zeichnen Änderungen, die das ganze Dokument neu zeichnen, auf der CPU. Klicken, um den GPU-Speicher einzustellen. + GPU memory budget: {budget} GPU-Speicherbudget: {budget} + This document's layers: {needed} of {budget} for layers Ebenen dieses Dokuments: {needed} von {budget} für Ebenen + This document's layers need {needed} but may hold {budget}: whole-document redraws use the CPU Die Ebenen dieses Dokuments brauchen {needed}, dürfen aber {budget} belegen: Das ganze Dokument wird auf der CPU neu gezeichnet + GPU Memory (MB, 0 = Automatic) GPU-Speicher (MB, 0 = Automatisch) diff --git a/crates/ui-egui/src/i18n/el.tsv b/crates/ui-egui/src/i18n/el.tsv index eccfd0508c..f3743db5c8 100644 --- a/crates/ui-egui/src/i18n/el.tsv +++ b/crates/ui-egui/src/i18n/el.tsv @@ -3439,7 +3439,6 @@ cameraRaw Yellow Κίτρινο HSB/HSL HSB/HSL Import Swatches… Εισαγωγή δειγμάτων χρωμάτων… Impressionist Ιμπρεσιονιστικό - Its layers don't fit the GPU memory budget. Raising Memory usage in Preferences › Performance raises the budget, up to a quarter of this computer's memory. Τα επίπεδά του δεν χωρούν στο όριο μνήμης της GPU. Αυξάνοντας τη Χρήση μνήμης στις Προτιμήσεις › Επιδόσεις αυξάνεται το όριο, έως το ένα τέταρτο της μνήμης αυτού του υπολογιστή. Knockout Διάτρηση Large document: redrawing on the CPU Μεγάλο έγγραφο: επανασχεδίαση στη CPU Redrawing Επανασχεδίαση @@ -3900,3 +3899,12 @@ curves Smooth Εξομάλυνση # Free Transform: a new layer while the box is open (#5859) Free Transform is still being applied; try again when it has finished. Ο Ελεύθερος μετασχηματισμός εφαρμόζεται ακόμη· δοκιμάστε ξανά όταν ολοκληρωθεί. The transform couldn't be applied. Δεν ήταν δυνατή η εφαρμογή του μετασχηματισμού. +# Preferences › Performance › GPU Memory; the status bar and slow-redraw notice + Its layers need {needed} of GPU memory; the budget leaves them {budget}. Τα επίπεδά του χρειάζονται {needed} μνήμης GPU· το όριο τους αφήνει {budget}. + Set GPU Memory in Preferences › Performance to what your graphics card can hold (Automatic follows Memory Usage, up to a quarter of this computer's memory). The status bar shows GPU Memory Full while they don't fit. Ορίστε τη Μνήμη GPU στις Προτιμήσεις › Επιδόσεις σε όσο χωράει η κάρτα γραφικών σας (το Αυτόματο ακολουθεί τη Χρήση μνήμης, έως το ένα τέταρτο της μνήμης αυτού του υπολογιστή). Όσο δεν χωρούν, η γραμμή κατάστασης δείχνει Η μνήμη GPU είναι πλήρης. + GPU Memory Full Η μνήμη GPU είναι πλήρης + The layers need {needed} of GPU memory and may hold {budget}, so edits that redraw the whole document do it on the CPU. Click to set GPU Memory. Τα επίπεδα χρειάζονται {needed} μνήμης GPU και μπορούν να καταλάβουν {budget}, οπότε οι αλλαγές που ξανασχεδιάζουν όλο το έγγραφο το κάνουν στη CPU. Κάντε κλικ για να ορίσετε τη Μνήμη GPU. + GPU memory budget: {budget} Όριο μνήμης GPU: {budget} + This document's layers: {needed} of {budget} for layers Επίπεδα αυτού του εγγράφου: {needed} από {budget} για επίπεδα + This document's layers need {needed} but may hold {budget}: whole-document redraws use the CPU Τα επίπεδα αυτού του εγγράφου χρειάζονται {needed} αλλά μπορούν να καταλάβουν {budget}: η επανασχεδίαση όλου του εγγράφου γίνεται στη CPU + GPU Memory (MB, 0 = Automatic) Μνήμη GPU (MB, 0 = Αυτόματο) diff --git a/crates/ui-egui/src/i18n/es.tsv b/crates/ui-egui/src/i18n/es.tsv index da9337eaea..3d00c696de 100644 --- a/crates/ui-egui/src/i18n/es.tsv +++ b/crates/ui-egui/src/i18n/es.tsv @@ -2512,7 +2512,6 @@ cameraRaw Amount Cantidad Redrawing Redibujando This redraw took {seconds} s on the CPU. Este redibujado en la CPU tardó {seconds} s. Edits that change the whole document, such as a layer's blend mode, opacity or visibility, redraw all of it. Las ediciones que cambian todo el documento, como el modo de fusión, la opacidad o la visibilidad de una capa, lo redibujan por completo. - Its layers don't fit the GPU memory budget. Raising Memory usage in Preferences › Performance raises the budget, up to a quarter of this computer's memory. Las capas no caben en el presupuesto de memoria de la GPU. Aumentar Uso de memoria en Preferencias › Rendimiento amplía el presupuesto, hasta una cuarta parte de la memoria de este equipo. The GPU compositor wasn't used: {reason} No se usó el compositor de GPU: {reason} fontWeight Thin Ultrafina fontWeight ExtraLight Extrafina @@ -4162,3 +4161,12 @@ navigator Zoom Zoom # Free Transform: a new layer while the box is open (#5859) Free Transform is still being applied; try again when it has finished. Transformación libre aún se está aplicando; inténtelo de nuevo cuando termine. The transform couldn't be applied. No se pudo aplicar la transformación. +# Preferences › Performance › GPU Memory; the status bar and slow-redraw notice + Its layers need {needed} of GPU memory; the budget leaves them {budget}. Sus capas necesitan {needed} de memoria de GPU; el presupuesto les deja {budget}. + Set GPU Memory in Preferences › Performance to what your graphics card can hold (Automatic follows Memory Usage, up to a quarter of this computer's memory). The status bar shows GPU Memory Full while they don't fit. Ajuste Memoria de GPU en Preferencias › Rendimiento a lo que pueda alojar su tarjeta gráfica (Automático sigue Uso de memoria, hasta una cuarta parte de la memoria de este equipo). Mientras no quepan, la barra de estado muestra Memoria de GPU llena. + GPU Memory Full Memoria de GPU llena + The layers need {needed} of GPU memory and may hold {budget}, so edits that redraw the whole document do it on the CPU. Click to set GPU Memory. Las capas necesitan {needed} de memoria de GPU y pueden ocupar {budget}, así que las ediciones que redibujan todo el documento lo hacen en la CPU. Haga clic para ajustar Memoria de GPU. + GPU memory budget: {budget} Presupuesto de memoria de GPU: {budget} + This document's layers: {needed} of {budget} for layers Capas de este documento: {needed} de {budget} para capas + This document's layers need {needed} but may hold {budget}: whole-document redraws use the CPU Las capas de este documento necesitan {needed} pero pueden ocupar {budget}: los redibujados de todo el documento usan la CPU + GPU Memory (MB, 0 = Automatic) Memoria de GPU (MB, 0 = Automático) diff --git a/crates/ui-egui/src/i18n/fr.tsv b/crates/ui-egui/src/i18n/fr.tsv index 8b25af722e..2aa12b16e6 100644 --- a/crates/ui-egui/src/i18n/fr.tsv +++ b/crates/ui-egui/src/i18n/fr.tsv @@ -2862,7 +2862,6 @@ cameraRaw Blending Fusion Redrawing Redessin This redraw took {seconds} s on the CPU. Ce redessin sur le CPU a pris {seconds} s. Edits that change the whole document, such as a layer's blend mode, opacity or visibility, redraw all of it. Les modifications qui touchent tout le document, comme le mode de fusion, l'opacité ou la visibilité d'un calque, le redessinent entièrement. - Its layers don't fit the GPU memory budget. Raising Memory usage in Preferences › Performance raises the budget, up to a quarter of this computer's memory. Les calques dépassent le budget de mémoire GPU. Augmenter Utilisation de la mémoire dans Préférences › Performances relève ce budget, jusqu'au quart de la mémoire de cet ordinateur. The GPU compositor wasn't used: {reason} Le compositeur GPU n'a pas été utilisé : {reason} fontWeight Thin Ultrafin fontWeight ExtraLight Extrafin @@ -3205,3 +3204,12 @@ curves Smooth Lisser # Free Transform: a new layer while the box is open (#5859) Free Transform is still being applied; try again when it has finished. La transformation manuelle est encore en cours d'application ; réessayez quand elle sera terminée. The transform couldn't be applied. Impossible d'appliquer la transformation. +# Preferences › Performance › GPU Memory; the status bar and slow-redraw notice + Its layers need {needed} of GPU memory; the budget leaves them {budget}. Ses calques ont besoin de {needed} de mémoire GPU ; le budget leur laisse {budget}. + Set GPU Memory in Preferences › Performance to what your graphics card can hold (Automatic follows Memory Usage, up to a quarter of this computer's memory). The status bar shows GPU Memory Full while they don't fit. Réglez Mémoire GPU dans Préférences › Performances sur ce que votre carte graphique peut contenir (Automatique suit Utilisation de la mémoire, jusqu'au quart de la mémoire de cet ordinateur). Tant qu'ils ne tiennent pas, la barre d'état affiche Mémoire GPU pleine. + GPU Memory Full Mémoire GPU pleine + The layers need {needed} of GPU memory and may hold {budget}, so edits that redraw the whole document do it on the CPU. Click to set GPU Memory. Les calques ont besoin de {needed} de mémoire GPU et peuvent en occuper {budget} : les modifications qui redessinent tout le document le font sur le CPU. Cliquez pour régler Mémoire GPU. + GPU memory budget: {budget} Budget de mémoire GPU : {budget} + This document's layers: {needed} of {budget} for layers Calques de ce document : {needed} sur {budget} pour les calques + This document's layers need {needed} but may hold {budget}: whole-document redraws use the CPU Les calques de ce document ont besoin de {needed} mais peuvent occuper {budget} : le document entier se redessine sur le CPU + GPU Memory (MB, 0 = Automatic) Mémoire GPU (Mo, 0 = Automatique) diff --git a/crates/ui-egui/src/i18n/hu.tsv b/crates/ui-egui/src/i18n/hu.tsv index 2870dc6f99..6d9eff4751 100644 --- a/crates/ui-egui/src/i18n/hu.tsv +++ b/crates/ui-egui/src/i18n/hu.tsv @@ -3312,7 +3312,6 @@ cameraRaw Yellow Sárga Interpret Footage Videoanyag értelmezése Interval Intervallum Invalid {mode} profile Érvénytelen profil ({mode}) - Its layers don't fit the GPU memory budget. Raising Memory usage in Preferences › Performance raises the budget, up to a quarter of this computer's memory. A dokumentum rétegei nem férnek el a GPU memóriakeretében. Ha a Beállítások › Teljesítmény alatt növeled a memóriahasználatot, a keret is nő, legfeljebb a számítógép memóriájának negyedéig. Join us on Discord Csatlakozz hozzánk a Discordon Keep Using CPU Marad a CPU Kernel Szűrőmag @@ -4119,3 +4118,12 @@ cameraRaw Yellow Sárga # Free Transform: a new layer while the box is open (#5859) Free Transform is still being applied; try again when it has finished. A szabad alakítás alkalmazása még folyamatban van; próbáld újra, ha befejeződött. The transform couldn't be applied. Az átalakítás nem alkalmazható. +# Preferences › Performance › GPU Memory; the status bar and slow-redraw notice + Its layers need {needed} of GPU memory; the budget leaves them {budget}. A rétegeknek {needed} GPU-memória kell; a keret {budget} hagy nekik. + Set GPU Memory in Preferences › Performance to what your graphics card can hold (Automatic follows Memory Usage, up to a quarter of this computer's memory). The status bar shows GPU Memory Full while they don't fit. A Beállítások › Teljesítmény alatt állítsd a GPU-memóriát arra, amennyit a videokártyád elbír (az Automatikus a memóriahasználatot követi, legfeljebb a számítógép memóriájának negyedéig). Amíg nem férnek el, az állapotsor a GPU-memória megtelt üzenetet mutatja. + GPU Memory Full GPU-memória megtelt + The layers need {needed} of GPU memory and may hold {budget}, so edits that redraw the whole document do it on the CPU. Click to set GPU Memory. A rétegeknek {needed} GPU-memória kell, és {budget} foglalhatnak, ezért a teljes dokumentumot újrarajzoló szerkesztések a CPU-n futnak. Kattints a GPU-memória beállításához. + GPU memory budget: {budget} GPU-memóriakeret: {budget} + This document's layers: {needed} of {budget} for layers A dokumentum rétegei: {needed} / {budget} a rétegeknek + This document's layers need {needed} but may hold {budget}: whole-document redraws use the CPU A dokumentum rétegeinek {needed} kell, de {budget} foglalhatnak: a teljes dokumentum újrarajzolása a CPU-n fut + GPU Memory (MB, 0 = Automatic) GPU-memória (MB, 0 = Automatikus) diff --git a/crates/ui-egui/src/i18n/id.tsv b/crates/ui-egui/src/i18n/id.tsv index feca83a9c7..dd972b82d8 100644 --- a/crates/ui-egui/src/i18n/id.tsv +++ b/crates/ui-egui/src/i18n/id.tsv @@ -2849,7 +2849,6 @@ cameraRaw Blending Pembauran Redrawing Menggambar ulang This redraw took {seconds} s on the CPU. Penggambaran ulang di CPU ini memakan waktu {seconds} dtk. Edits that change the whole document, such as a layer's blend mode, opacity or visibility, redraw all of it. Suntingan yang mengubah seluruh dokumen, seperti mode campuran, opasitas, atau visibilitas layer, menggambar ulang semuanya. - Its layers don't fit the GPU memory budget. Raising Memory usage in Preferences › Performance raises the budget, up to a quarter of this computer's memory. Layer tidak muat dalam anggaran memori GPU. Menaikkan Pemakaian memori di Preferensi › Performa menaikkan anggaran, hingga seperempat memori komputer ini. The GPU compositor wasn't used: {reason} Kompositor GPU tidak digunakan: {reason} fontWeight Thin Sangat Tipis fontWeight ExtraLight Ekstra Tipis @@ -3192,3 +3191,12 @@ curves Smooth Haluskan # Free Transform: a new layer while the box is open (#5859) Free Transform is still being applied; try again when it has finished. Transformasi Bebas masih diterapkan; coba lagi setelah selesai. The transform couldn't be applied. Transformasi tidak dapat diterapkan. +# Preferences › Performance › GPU Memory; the status bar and slow-redraw notice + Its layers need {needed} of GPU memory; the budget leaves them {budget}. Layer-nya membutuhkan {needed} memori GPU; anggaran menyisakan {budget}. + Set GPU Memory in Preferences › Performance to what your graphics card can hold (Automatic follows Memory Usage, up to a quarter of this computer's memory). The status bar shows GPU Memory Full while they don't fit. Atur Memori GPU di Preferensi › Performa sesuai kapasitas kartu grafis Anda (Otomatis mengikuti Pemakaian memori, hingga seperempat memori komputer ini). Selama belum muat, bilah status menampilkan Memori GPU Penuh. + GPU Memory Full Memori GPU Penuh + The layers need {needed} of GPU memory and may hold {budget}, so edits that redraw the whole document do it on the CPU. Click to set GPU Memory. Layer membutuhkan {needed} memori GPU dan boleh memakai {budget}, sehingga suntingan yang menggambar ulang seluruh dokumen melakukannya di CPU. Klik untuk mengatur Memori GPU. + GPU memory budget: {budget} Anggaran memori GPU: {budget} + This document's layers: {needed} of {budget} for layers Layer dokumen ini: {needed} dari {budget} untuk layer + This document's layers need {needed} but may hold {budget}: whole-document redraws use the CPU Layer dokumen ini membutuhkan {needed} tetapi boleh memakai {budget}: penggambaran ulang seluruh dokumen memakai CPU + GPU Memory (MB, 0 = Automatic) Memori GPU (MB, 0 = Otomatis) diff --git a/crates/ui-egui/src/i18n/it.tsv b/crates/ui-egui/src/i18n/it.tsv index a8796e64e7..1b76fdee65 100644 --- a/crates/ui-egui/src/i18n/it.tsv +++ b/crates/ui-egui/src/i18n/it.tsv @@ -2512,7 +2512,6 @@ cameraRaw Amount Quantità Redrawing Ridisegno This redraw took {seconds} s on the CPU. Questo ridisegno sulla CPU ha richiesto {seconds} s. Edits that change the whole document, such as a layer's blend mode, opacity or visibility, redraw all of it. Le modifiche che cambiano tutto il documento, come metodo di fusione, opacità o visibilità di un livello, lo ridisegnano per intero. - Its layers don't fit the GPU memory budget. Raising Memory usage in Preferences › Performance raises the budget, up to a quarter of this computer's memory. I livelli non rientrano nel budget di memoria della GPU. Aumentare Uso memoria in Preferenze › Prestazioni alza il budget, fino a un quarto della memoria di questo computer. The GPU compositor wasn't used: {reason} Il compositore GPU non è stato usato: {reason} fontWeight Thin Sottile fontWeight ExtraLight Extraleggero @@ -4321,3 +4320,12 @@ shape Appearance Aspetto # Free Transform: a new layer while the box is open (#5859) Free Transform is still being applied; try again when it has finished. Trasformazione libera è ancora in corso di applicazione; riprova quando è terminata. The transform couldn't be applied. Impossibile applicare la trasformazione. +# Preferences › Performance › GPU Memory; the status bar and slow-redraw notice + Its layers need {needed} of GPU memory; the budget leaves them {budget}. I livelli richiedono {needed} di memoria GPU; il budget ne lascia {budget}. + Set GPU Memory in Preferences › Performance to what your graphics card can hold (Automatic follows Memory Usage, up to a quarter of this computer's memory). The status bar shows GPU Memory Full while they don't fit. Imposta Memoria GPU in Preferenze › Prestazioni su quanto può contenere la tua scheda grafica (Automatico segue Uso memoria, fino a un quarto della memoria di questo computer). Finché non ci stanno, la barra di stato mostra Memoria GPU piena. + GPU Memory Full Memoria GPU piena + The layers need {needed} of GPU memory and may hold {budget}, so edits that redraw the whole document do it on the CPU. Click to set GPU Memory. I livelli richiedono {needed} di memoria GPU e possono occuparne {budget}, quindi le modifiche che ridisegnano tutto il documento lo fanno sulla CPU. Fai clic per impostare Memoria GPU. + GPU memory budget: {budget} Budget di memoria GPU: {budget} + This document's layers: {needed} of {budget} for layers Livelli di questo documento: {needed} su {budget} per i livelli + This document's layers need {needed} but may hold {budget}: whole-document redraws use the CPU I livelli di questo documento richiedono {needed} ma possono occupare {budget}: i ridisegni dell'intero documento usano la CPU + GPU Memory (MB, 0 = Automatic) Memoria GPU (MB, 0 = Automatico) diff --git a/crates/ui-egui/src/i18n/ja.tsv b/crates/ui-egui/src/i18n/ja.tsv index 13efa7517d..2b064edcdf 100644 --- a/crates/ui-egui/src/i18n/ja.tsv +++ b/crates/ui-egui/src/i18n/ja.tsv @@ -2413,7 +2413,6 @@ cameraRaw Color カラー Redrawing 再描画中 This redraw took {seconds} s on the CPU. この再描画は CPU で {seconds} 秒かかりました。 Edits that change the whole document, such as a layer's blend mode, opacity or visibility, redraw all of it. レイヤーの描画モード、不透明度、表示/非表示など、ドキュメント全体に関わる編集では全体が再描画されます。 - Its layers don't fit the GPU memory budget. Raising Memory usage in Preferences › Performance raises the budget, up to a quarter of this computer's memory. レイヤーが GPU メモリの予算に収まりません。環境設定 › パフォーマンスのメモリ使用量を増やすと、このコンピューターのメモリの4分の1まで予算が増えます。 The GPU compositor wasn't used: {reason} GPU コンポジターは使用されませんでした: {reason} fontWeight Thin 極細 fontWeight ExtraLight 特細 @@ -3876,3 +3875,12 @@ curves Smooth スムーズ # Free Transform: a new layer while the box is open (#5859) Free Transform is still being applied; try again when it has finished. 自由変形を適用中です。完了してからもう一度お試しください。 The transform couldn't be applied. 変形を適用できませんでした。 +# Preferences › Performance › GPU Memory; the status bar and slow-redraw notice + Its layers need {needed} of GPU memory; the budget leaves them {budget}. レイヤーには {needed} の GPU メモリが必要ですが、予算で使えるのは {budget} です。 + Set GPU Memory in Preferences › Performance to what your graphics card can hold (Automatic follows Memory Usage, up to a quarter of this computer's memory). The status bar shows GPU Memory Full while they don't fit. 環境設定 › パフォーマンスの GPU メモリを、グラフィックカードが保持できる量に設定してください(自動はメモリ使用量に従い、このコンピューターのメモリの4分の1までです)。収まらない間、ステータスバーに「GPU メモリ不足」と表示されます。 + GPU Memory Full GPU メモリ不足 + The layers need {needed} of GPU memory and may hold {budget}, so edits that redraw the whole document do it on the CPU. Click to set GPU Memory. レイヤーには {needed} の GPU メモリが必要ですが、使えるのは {budget} のため、ドキュメント全体を再描画する編集は CPU で行われます。クリックして GPU メモリを設定します。 + GPU memory budget: {budget} GPU メモリ予算: {budget} + This document's layers: {needed} of {budget} for layers このドキュメントのレイヤー: レイヤー用 {budget} のうち {needed} + This document's layers need {needed} but may hold {budget}: whole-document redraws use the CPU このドキュメントのレイヤーには {needed} が必要ですが、使えるのは {budget} です: ドキュメント全体の再描画は CPU で行われます + GPU Memory (MB, 0 = Automatic) GPU メモリ(MB、0 = 自動) diff --git a/crates/ui-egui/src/i18n/ko.tsv b/crates/ui-egui/src/i18n/ko.tsv index 5354b04a31..5248924d6e 100644 --- a/crates/ui-egui/src/i18n/ko.tsv +++ b/crates/ui-egui/src/i18n/ko.tsv @@ -3022,7 +3022,6 @@ cameraRaw Color 색상 Redrawing 다시 그리는 중 This redraw took {seconds} s on the CPU. 이번 다시 그리기는 CPU에서 {seconds}초 걸렸습니다. Edits that change the whole document, such as a layer's blend mode, opacity or visibility, redraw all of it. 레이어의 혼합 모드, 불투명도, 표시 여부처럼 문서 전체를 바꾸는 편집은 문서 전체를 다시 그립니다. - Its layers don't fit the GPU memory budget. Raising Memory usage in Preferences › Performance raises the budget, up to a quarter of this computer's memory. 레이어가 GPU 메모리 예산에 맞지 않습니다. 환경 설정 › 성능에서 메모리 사용량을 늘리면 이 컴퓨터 메모리의 4분의 1까지 예산이 늘어납니다. The GPU compositor wasn't used: {reason} GPU 합성기를 사용하지 않았습니다: {reason} fontWeight Thin 아주 가늘게 fontWeight ExtraLight 더 가늘게 @@ -3378,3 +3377,12 @@ curves Smooth 매끄럽게 # Free Transform: a new layer while the box is open (#5859) Free Transform is still being applied; try again when it has finished. 자유 변형을 아직 적용하는 중입니다. 완료된 후 다시 시도하세요. The transform couldn't be applied. 변형을 적용할 수 없습니다. +# Preferences › Performance › GPU Memory; the status bar and slow-redraw notice + Its layers need {needed} of GPU memory; the budget leaves them {budget}. 레이어에 GPU 메모리 {needed}가 필요하지만 예산은 {budget}만 남겨 둡니다. + Set GPU Memory in Preferences › Performance to what your graphics card can hold (Automatic follows Memory Usage, up to a quarter of this computer's memory). The status bar shows GPU Memory Full while they don't fit. 환경 설정 › 성능에서 GPU 메모리를 그래픽 카드가 감당할 수 있는 양으로 설정하세요(자동은 메모리 사용량을 따르며 이 컴퓨터 메모리의 4분의 1까지입니다). 맞지 않는 동안 상태 표시줄에 GPU 메모리 부족이 표시됩니다. + GPU Memory Full GPU 메모리 부족 + The layers need {needed} of GPU memory and may hold {budget}, so edits that redraw the whole document do it on the CPU. Click to set GPU Memory. 레이어에 GPU 메모리 {needed}가 필요하지만 {budget}만 사용할 수 있어 문서 전체를 다시 그리는 편집은 CPU에서 처리됩니다. 클릭하여 GPU 메모리를 설정합니다. + GPU memory budget: {budget} GPU 메모리 예산: {budget} + This document's layers: {needed} of {budget} for layers 이 문서의 레이어: 레이어용 {budget} 중 {needed} + This document's layers need {needed} but may hold {budget}: whole-document redraws use the CPU 이 문서의 레이어에 {needed}가 필요하지만 {budget}만 사용할 수 있습니다: 문서 전체 다시 그리기는 CPU를 사용합니다 + GPU Memory (MB, 0 = Automatic) GPU 메모리(MB, 0 = 자동) diff --git a/crates/ui-egui/src/i18n/nl.tsv b/crates/ui-egui/src/i18n/nl.tsv index 5a013c7862..efb03ed9f4 100644 --- a/crates/ui-egui/src/i18n/nl.tsv +++ b/crates/ui-egui/src/i18n/nl.tsv @@ -2441,7 +2441,6 @@ layerLabel No Color Geen kleur Redrawing Opnieuw tekenen This redraw took {seconds} s on the CPU. Opnieuw tekenen duurde {seconds} s op de CPU. Edits that change the whole document, such as a layer's blend mode, opacity or visibility, redraw all of it. Bewerkingen die het hele document veranderen, zoals de overvloeimodus, dekking of zichtbaarheid van een laag, tekenen alles opnieuw. - Its layers don't fit the GPU memory budget. Raising Memory usage in Preferences › Performance raises the budget, up to a quarter of this computer's memory. De lagen passen niet binnen het GPU-geheugenbudget. Door Geheugengebruik te verhogen in Voorkeuren › Prestaties wordt het budget groter, tot een kwart van het geheugen van deze computer. The GPU compositor wasn't used: {reason} De GPU-compositor is niet gebruikt: {reason} cameraRaw Light Licht fontWeight Thin Dun @@ -3960,3 +3959,12 @@ curves Smooth Vloeiend maken # Free Transform: a new layer while the box is open (#5859) Free Transform is still being applied; try again when it has finished. Vrije transformatie wordt nog toegepast; probeer het opnieuw als die klaar is. The transform couldn't be applied. De transformatie kon niet worden toegepast. +# Preferences › Performance › GPU Memory; the status bar and slow-redraw notice + Its layers need {needed} of GPU memory; the budget leaves them {budget}. De lagen hebben {needed} GPU-geheugen nodig; het budget laat ze {budget}. + Set GPU Memory in Preferences › Performance to what your graphics card can hold (Automatic follows Memory Usage, up to a quarter of this computer's memory). The status bar shows GPU Memory Full while they don't fit. Stel GPU-geheugen in bij Voorkeuren › Prestaties op wat uw videokaart aankan (Automatisch volgt Geheugengebruik, tot een kwart van het geheugen van deze computer). Zolang ze niet passen, toont de statusbalk GPU-geheugen vol. + GPU Memory Full GPU-geheugen vol + The layers need {needed} of GPU memory and may hold {budget}, so edits that redraw the whole document do it on the CPU. Click to set GPU Memory. De lagen hebben {needed} GPU-geheugen nodig en mogen {budget} gebruiken, dus bewerkingen die het hele document opnieuw tekenen doen dat op de CPU. Klik om GPU-geheugen in te stellen. + GPU memory budget: {budget} GPU-geheugenbudget: {budget} + This document's layers: {needed} of {budget} for layers Lagen van dit document: {needed} van {budget} voor lagen + This document's layers need {needed} but may hold {budget}: whole-document redraws use the CPU De lagen van dit document hebben {needed} nodig maar mogen {budget} gebruiken: het hele document wordt op de CPU opnieuw getekend + GPU Memory (MB, 0 = Automatic) GPU-geheugen (MB, 0 = Automatisch) diff --git a/crates/ui-egui/src/i18n/pl.tsv b/crates/ui-egui/src/i18n/pl.tsv index 8707b5fb2e..31ed39fab3 100644 --- a/crates/ui-egui/src/i18n/pl.tsv +++ b/crates/ui-egui/src/i18n/pl.tsv @@ -3471,7 +3471,6 @@ cameraRaw Roughness Chropowatość Redrawing Odświeżanie This redraw took {seconds} s on the CPU. To odświeżenie na CPU trwało {seconds} s. Edits that change the whole document, such as a layer's blend mode, opacity or visibility, redraw all of it. Zmiany obejmujące cały dokument, takie jak tryb mieszania, krycie lub widoczność warstwy, odświeżają go w całości. - Its layers don't fit the GPU memory budget. Raising Memory usage in Preferences › Performance raises the budget, up to a quarter of this computer's memory. Warstwy nie mieszczą się w budżecie pamięci GPU. Zwiększenie wartości Użycie pamięci w Preferencje › Wydajność zwiększa budżet, maksymalnie do jednej czwartej pamięci tego komputera. The GPU compositor wasn't used: {reason} Kompozytor GPU nie został użyty: {reason} # Pattern Stamp tool Pattern Stamp Tool Narzędzie Pieczątka wzoru @@ -3798,3 +3797,12 @@ curves Smooth Wygładź # Free Transform: a new layer while the box is open (#5859) Free Transform is still being applied; try again when it has finished. Przekształcanie swobodne jest nadal stosowane; spróbuj ponownie, gdy się zakończy. The transform couldn't be applied. Nie można zastosować przekształcenia. +# Preferences › Performance › GPU Memory; the status bar and slow-redraw notice + Its layers need {needed} of GPU memory; the budget leaves them {budget}. Warstwy potrzebują {needed} pamięci GPU; budżet zostawia im {budget}. + Set GPU Memory in Preferences › Performance to what your graphics card can hold (Automatic follows Memory Usage, up to a quarter of this computer's memory). The status bar shows GPU Memory Full while they don't fit. Ustaw Pamięć GPU w Preferencje › Wydajność na tyle, ile zmieści Twoja karta graficzna (Automatycznie podąża za Użyciem pamięci, maksymalnie do jednej czwartej pamięci tego komputera). Dopóki się nie mieszczą, pasek stanu pokazuje Pamięć GPU pełna. + GPU Memory Full Pamięć GPU pełna + The layers need {needed} of GPU memory and may hold {budget}, so edits that redraw the whole document do it on the CPU. Click to set GPU Memory. Warstwy potrzebują {needed} pamięci GPU i mogą zająć {budget}, więc edycje, które odświeżają cały dokument, robią to na CPU. Kliknij, aby ustawić Pamięć GPU. + GPU memory budget: {budget} Budżet pamięci GPU: {budget} + This document's layers: {needed} of {budget} for layers Warstwy tego dokumentu: {needed} z {budget} na warstwy + This document's layers need {needed} but may hold {budget}: whole-document redraws use the CPU Warstwy tego dokumentu potrzebują {needed}, ale mogą zająć {budget}: odświeżanie całego dokumentu używa CPU + GPU Memory (MB, 0 = Automatic) Pamięć GPU (MB, 0 = Automatycznie) diff --git a/crates/ui-egui/src/i18n/pt-br.tsv b/crates/ui-egui/src/i18n/pt-br.tsv index ef10787ceb..5f0b254f64 100644 --- a/crates/ui-egui/src/i18n/pt-br.tsv +++ b/crates/ui-egui/src/i18n/pt-br.tsv @@ -2865,7 +2865,6 @@ cameraRaw Whites Brancos Redrawing Redesenhando This redraw took {seconds} s on the CPU. Este redesenho na CPU levou {seconds} s. Edits that change the whole document, such as a layer's blend mode, opacity or visibility, redraw all of it. Edições que mudam o documento inteiro, como modo de mesclagem, opacidade ou visibilidade de uma camada, redesenham tudo. - Its layers don't fit the GPU memory budget. Raising Memory usage in Preferences › Performance raises the budget, up to a quarter of this computer's memory. As camadas não cabem no orçamento de memória da GPU. Aumentar Uso de memória em Preferências › Desempenho eleva o orçamento, até um quarto da memória deste computador. The GPU compositor wasn't used: {reason} O compositor de GPU não foi usado: {reason} fontWeight Thin Fina fontWeight ExtraLight Extrafina @@ -3990,3 +3989,12 @@ layerLabel No Color Sem cor # Free Transform: a new layer while the box is open (#5859) Free Transform is still being applied; try again when it has finished. A Transformação livre ainda está sendo aplicada; tente novamente quando terminar. The transform couldn't be applied. Não foi possível aplicar a transformação. +# Preferences › Performance › GPU Memory; the status bar and slow-redraw notice + Its layers need {needed} of GPU memory; the budget leaves them {budget}. As camadas precisam de {needed} de memória da GPU; o orçamento lhes deixa {budget}. + Set GPU Memory in Preferences › Performance to what your graphics card can hold (Automatic follows Memory Usage, up to a quarter of this computer's memory). The status bar shows GPU Memory Full while they don't fit. Ajuste Memória da GPU em Preferências › Desempenho para o que sua placa de vídeo comporta (Automático segue Uso de memória, até um quarto da memória deste computador). Enquanto não couberem, a barra de status mostra Memória da GPU cheia. + GPU Memory Full Memória da GPU cheia + The layers need {needed} of GPU memory and may hold {budget}, so edits that redraw the whole document do it on the CPU. Click to set GPU Memory. As camadas precisam de {needed} de memória da GPU e podem ocupar {budget}, então as edições que redesenham o documento inteiro fazem isso na CPU. Clique para ajustar Memória da GPU. + GPU memory budget: {budget} Orçamento de memória da GPU: {budget} + This document's layers: {needed} of {budget} for layers Camadas deste documento: {needed} de {budget} para camadas + This document's layers need {needed} but may hold {budget}: whole-document redraws use the CPU As camadas deste documento precisam de {needed} mas podem ocupar {budget}: o documento inteiro é redesenhado na CPU + GPU Memory (MB, 0 = Automatic) Memória da GPU (MB, 0 = Automático) diff --git a/crates/ui-egui/src/i18n/ru.tsv b/crates/ui-egui/src/i18n/ru.tsv index 449f741e21..03247c0c49 100644 --- a/crates/ui-egui/src/i18n/ru.tsv +++ b/crates/ui-egui/src/i18n/ru.tsv @@ -2712,7 +2712,6 @@ cameraRaw Roughness Неровность Redrawing Перерисовка This redraw took {seconds} s on the CPU. Эта перерисовка на CPU заняла {seconds} с. Edits that change the whole document, such as a layer's blend mode, opacity or visibility, redraw all of it. Правки, затрагивающие весь документ, например режим наложения, непрозрачность или видимость слоя, перерисовывают его целиком. - Its layers don't fit the GPU memory budget. Raising Memory usage in Preferences › Performance raises the budget, up to a quarter of this computer's memory. Слои не помещаются в бюджет памяти GPU. Увеличение параметра «Память» в разделе Настройки › Производительность повышает бюджет, но не больше четверти памяти этого компьютера. The GPU compositor wasn't used: {reason} Компоновщик GPU не использовался: {reason} fontWeight Thin Тонкий fontWeight ExtraLight Сверхтонкий @@ -3170,3 +3169,12 @@ curves Smooth Сгладить # Free Transform: a new layer while the box is open (#5859) Free Transform is still being applied; try again when it has finished. Свободное трансформирование ещё применяется; повторите попытку, когда оно завершится. The transform couldn't be applied. Не удалось применить трансформирование. +# Preferences › Performance › GPU Memory; the status bar and slow-redraw notice + Its layers need {needed} of GPU memory; the budget leaves them {budget}. Слоям нужно {needed} памяти GPU; бюджет оставляет им {budget}. + Set GPU Memory in Preferences › Performance to what your graphics card can hold (Automatic follows Memory Usage, up to a quarter of this computer's memory). The status bar shows GPU Memory Full while they don't fit. Задайте «Память GPU» в разделе Настройки › Производительность по объёму, который вмещает ваша видеокарта («Автоматически» следует параметру «Память», но не больше четверти памяти этого компьютера). Пока слои не помещаются, в строке состояния показано «Память GPU заполнена». + GPU Memory Full Память GPU заполнена + The layers need {needed} of GPU memory and may hold {budget}, so edits that redraw the whole document do it on the CPU. Click to set GPU Memory. Слоям нужно {needed} памяти GPU, а занимать они могут {budget}, поэтому правки, перерисовывающие весь документ, выполняются на CPU. Нажмите, чтобы задать «Память GPU». + GPU memory budget: {budget} Бюджет памяти GPU: {budget} + This document's layers: {needed} of {budget} for layers Слои этого документа: {needed} из {budget} для слоёв + This document's layers need {needed} but may hold {budget}: whole-document redraws use the CPU Слоям этого документа нужно {needed}, но занимать они могут {budget}: весь документ перерисовывается на CPU + GPU Memory (MB, 0 = Automatic) Память GPU (МБ, 0 = автоматически) diff --git a/crates/ui-egui/src/i18n/sr.tsv b/crates/ui-egui/src/i18n/sr.tsv index 4dedfaf7a1..d63ea98c5c 100644 --- a/crates/ui-egui/src/i18n/sr.tsv +++ b/crates/ui-egui/src/i18n/sr.tsv @@ -2836,7 +2836,6 @@ cameraRaw Blending Stapanje Large document: redrawing on the CPU Veliki dokument: ponovno iscrtavanje procesorom This redraw took {seconds} s on the CPU. Ovo iscrtavanje je trajalo {seconds} s na procesoru. Edits that change the whole document, such as a layer's blend mode, opacity or visibility, redraw all of it. Izmene koje menjaju ceo dokument, kao što su režim stapanja, neprozirnost ili vidljivost sloja, ponovo iscrtavaju ceo dokument. - Its layers don't fit the GPU memory budget. Raising Memory usage in Preferences › Performance raises the budget, up to a quarter of this computer's memory. Njegovi slojevi ne staju u budžet GPU memorije. Povećanje vrednosti Korišćenje memorije u Podešavanja › Performanse povećava budžet, do četvrtine memorije ovog računara. The GPU compositor wasn't used: {reason} GPU kompozitor nije korišćen: {reason} fontWeight Thin Tanko fontWeight ExtraLight Ekstra lagano @@ -4273,3 +4272,12 @@ shape Appearance Izgled # Free Transform: a new layer while the box is open (#5859) Free Transform is still being applied; try again when it has finished. Slobodna transformacija se još primenjuje; pokušajte ponovo kad se završi. The transform couldn't be applied. Transformacija nije mogla da se primeni. +# Preferences › Performance › GPU Memory; the status bar and slow-redraw notice + Its layers need {needed} of GPU memory; the budget leaves them {budget}. Njegovim slojevima treba {needed} GPU memorije; budžet im ostavlja {budget}. + Set GPU Memory in Preferences › Performance to what your graphics card can hold (Automatic follows Memory Usage, up to a quarter of this computer's memory). The status bar shows GPU Memory Full while they don't fit. Podesite GPU memoriju u Podešavanja › Performanse na onoliko koliko vaša grafička kartica može da primi (Automatski prati Korišćenje memorije, do četvrtine memorije ovog računara). Dok ne staju, statusna traka prikazuje GPU memorija je puna. + GPU Memory Full GPU memorija je puna + The layers need {needed} of GPU memory and may hold {budget}, so edits that redraw the whole document do it on the CPU. Click to set GPU Memory. Slojevima treba {needed} GPU memorije, a smeju da zauzmu {budget}, pa izmene koje ponovo iscrtavaju ceo dokument to rade na CPU-u. Kliknite da podesite GPU memoriju. + GPU memory budget: {budget} Budžet GPU memorije: {budget} + This document's layers: {needed} of {budget} for layers Slojevi ovog dokumenta: {needed} od {budget} za slojeve + This document's layers need {needed} but may hold {budget}: whole-document redraws use the CPU Slojevima ovog dokumenta treba {needed}, a smeju da zauzmu {budget}: ceo dokument se ponovo iscrtava na CPU-u + GPU Memory (MB, 0 = Automatic) GPU memorija (MB, 0 = Automatski) diff --git a/crates/ui-egui/src/i18n/tr.tsv b/crates/ui-egui/src/i18n/tr.tsv index f7dc6961fa..aaa50e4791 100644 --- a/crates/ui-egui/src/i18n/tr.tsv +++ b/crates/ui-egui/src/i18n/tr.tsv @@ -2410,7 +2410,6 @@ layerLabel No Color Renk Yok Large document: redrawing on the CPU Büyük belge: CPU'da yeniden çizim This redraw took {seconds} s on the CPU. Bu yeniden çizim CPU'da {seconds} saniye sürdü. Edits that change the whole document, such as a layer's blend mode, opacity or visibility, redraw all of it. Bir katmanın karışım modu, opaklığı veya görünürlüğü gibi belgenin tamamını değiştiren düzenlemeler, belgenin tamamını yeniden çizer. - Its layers don't fit the GPU memory budget. Raising Memory usage in Preferences › Performance raises the budget, up to a quarter of this computer's memory. Katmanları GPU bellek bütçesine uymuyor. Tercihler'de Bellek kullanımını artırma › Performans, bütçeyi bu bilgisayarın belleğinin dörtte birine kadar artırır. The GPU compositor wasn't used: {reason} GPU dizgicisi kullanılmadı: {reason} cameraRaw Light Açık Tonlar fontWeight Thin İnce @@ -3930,3 +3929,12 @@ cameraData Lens Lens # Free Transform: a new layer while the box is open (#5859) Free Transform is still being applied; try again when it has finished. Serbest Dönüştürme hâlâ uygulanıyor; bittiğinde yeniden deneyin. The transform couldn't be applied. Dönüştürme uygulanamadı. +# Preferences › Performance › GPU Memory; the status bar and slow-redraw notice + Its layers need {needed} of GPU memory; the budget leaves them {budget}. Katmanlarının {needed} GPU belleğine ihtiyacı var; bütçe onlara {budget} bırakıyor. + Set GPU Memory in Preferences › Performance to what your graphics card can hold (Automatic follows Memory Usage, up to a quarter of this computer's memory). The status bar shows GPU Memory Full while they don't fit. Tercihler › Performans içindeki GPU Belleği'ni ekran kartınızın kaldırabileceği miktara ayarlayın (Otomatik, Bellek kullanımını izler; en fazla bu bilgisayarın belleğinin dörtte biri). Sığmadıkları sürece durum çubuğu GPU Belleği Dolu gösterir. + GPU Memory Full GPU Belleği Dolu + The layers need {needed} of GPU memory and may hold {budget}, so edits that redraw the whole document do it on the CPU. Click to set GPU Memory. Katmanların {needed} GPU belleğine ihtiyacı var ve en fazla {budget} kullanabilirler; bu yüzden tüm belgeyi yeniden çizen düzenlemeler bunu CPU'da yapar. GPU Belleği'ni ayarlamak için tıklayın. + GPU memory budget: {budget} GPU bellek bütçesi: {budget} + This document's layers: {needed} of {budget} for layers Bu belgenin katmanları: katmanlar için {budget} içinden {needed} + This document's layers need {needed} but may hold {budget}: whole-document redraws use the CPU Bu belgenin katmanlarının {needed} belleğe ihtiyacı var ama en fazla {budget} kullanabilirler: tüm belgenin yeniden çizimi CPU'yu kullanır + GPU Memory (MB, 0 = Automatic) GPU Belleği (MB, 0 = Otomatik) diff --git a/crates/ui-egui/src/i18n/uk.tsv b/crates/ui-egui/src/i18n/uk.tsv index 9a018c744a..aa0fabff4d 100644 --- a/crates/ui-egui/src/i18n/uk.tsv +++ b/crates/ui-egui/src/i18n/uk.tsv @@ -2679,7 +2679,6 @@ cameraRaw Roughness Шорсткість Redrawing Перемальовування This redraw took {seconds} s on the CPU. Це перемальовування на CPU тривало {seconds} с. Edits that change the whole document, such as a layer's blend mode, opacity or visibility, redraw all of it. Зміни, що впливають на весь документ, як-от режим накладання, непрозорість чи видимість шару, перемальовують його повністю. - Its layers don't fit the GPU memory budget. Raising Memory usage in Preferences › Performance raises the budget, up to a quarter of this computer's memory. Його шари не вміщуються в бюджет пам'яті GPU. Збільшення використання пам'яті в Налаштування › Продуктивність підвищує бюджет до чверті пам'яті цього комп'ютера. The GPU compositor wasn't used: {reason} Компонувальник GPU не використовувався: {reason} fontWeight Thin Тонкий fontWeight ExtraLight Екстралегкий @@ -3899,3 +3898,12 @@ curves Smooth Згладити # Free Transform: a new layer while the box is open (#5859) Free Transform is still being applied; try again when it has finished. Вільне трансформування ще застосовується; спробуйте знову, коли воно завершиться. The transform couldn't be applied. Не вдалося застосувати трансформування. +# Preferences › Performance › GPU Memory; the status bar and slow-redraw notice + Its layers need {needed} of GPU memory; the budget leaves them {budget}. Його шарам потрібно {needed} пам'яті GPU; бюджет залишає їм {budget}. + Set GPU Memory in Preferences › Performance to what your graphics card can hold (Automatic follows Memory Usage, up to a quarter of this computer's memory). The status bar shows GPU Memory Full while they don't fit. Задайте «Пам'ять GPU» в Налаштування › Продуктивність відповідно до того, скільки вміщує ваша відеокарта («Автоматично» стежить за використанням пам'яті, до чверті пам'яті цього комп'ютера). Поки шари не вміщуються, рядок стану показує «Пам'ять GPU заповнена». + GPU Memory Full Пам'ять GPU заповнена + The layers need {needed} of GPU memory and may hold {budget}, so edits that redraw the whole document do it on the CPU. Click to set GPU Memory. Шарам потрібно {needed} пам'яті GPU, а займати вони можуть {budget}, тому зміни, що перемальовують увесь документ, виконуються на CPU. Натисніть, щоб задати «Пам'ять GPU». + GPU memory budget: {budget} Бюджет пам'яті GPU: {budget} + This document's layers: {needed} of {budget} for layers Шари цього документа: {needed} з {budget} для шарів + This document's layers need {needed} but may hold {budget}: whole-document redraws use the CPU Шарам цього документа потрібно {needed}, але займати вони можуть {budget}: увесь документ перемальовується на CPU + GPU Memory (MB, 0 = Automatic) Пам'ять GPU (МБ, 0 = автоматично) diff --git a/crates/ui-egui/src/i18n/vi.tsv b/crates/ui-egui/src/i18n/vi.tsv index b798849d72..49ad7f59fc 100644 --- a/crates/ui-egui/src/i18n/vi.tsv +++ b/crates/ui-egui/src/i18n/vi.tsv @@ -2156,7 +2156,6 @@ layers Fill: Độ tô: Imported {n} shortcuts from {file}. Click OK to keep them. Đã nhập {n} phím tắt từ {file}. Nhấp OK để giữ. Indexed Color… Màu lập chỉ mục… Install Plug-in… Cài plug-in… - Its layers don't fit the GPU memory budget. Raising Memory usage in Preferences › Performance raises the budget, up to a quarter of this computer's memory. Các lớp không vừa ngân sách bộ nhớ GPU. Tăng mức sử dụng bộ nhớ trong Tùy chọn › Hiệu năng sẽ nâng ngân sách, tối đa một phần tư bộ nhớ máy này. Labels Nhãn Large document: redrawing on the CPU Tài liệu lớn: đang vẽ lại trên CPU Redrawing Đang vẽ lại @@ -3686,3 +3685,12 @@ curves Smooth Làm mượt # Free Transform: a new layer while the box is open (#5859) Free Transform is still being applied; try again when it has finished. Biến đổi tự do vẫn đang được áp dụng; hãy thử lại khi hoàn tất. The transform couldn't be applied. Không thể áp dụng phép biến đổi. +# Preferences › Performance › GPU Memory; the status bar and slow-redraw notice + Its layers need {needed} of GPU memory; the budget leaves them {budget}. Các lớp cần {needed} bộ nhớ GPU; ngân sách chỉ dành cho chúng {budget}. + Set GPU Memory in Preferences › Performance to what your graphics card can hold (Automatic follows Memory Usage, up to a quarter of this computer's memory). The status bar shows GPU Memory Full while they don't fit. Đặt Bộ nhớ GPU trong Tùy chọn › Hiệu năng bằng mức card đồ họa của bạn chứa được (Tự động theo mức sử dụng bộ nhớ, tối đa một phần tư bộ nhớ máy này). Khi chúng chưa vừa, thanh trạng thái hiện Bộ nhớ GPU đầy. + GPU Memory Full Bộ nhớ GPU đầy + The layers need {needed} of GPU memory and may hold {budget}, so edits that redraw the whole document do it on the CPU. Click to set GPU Memory. Các lớp cần {needed} bộ nhớ GPU và chỉ được dùng {budget}, nên những chỉnh sửa vẽ lại toàn bộ tài liệu sẽ làm trên CPU. Nhấp để đặt Bộ nhớ GPU. + GPU memory budget: {budget} Ngân sách bộ nhớ GPU: {budget} + This document's layers: {needed} of {budget} for layers Các lớp của tài liệu này: {needed} trên {budget} dành cho lớp + This document's layers need {needed} but may hold {budget}: whole-document redraws use the CPU Các lớp của tài liệu này cần {needed} nhưng chỉ được dùng {budget}: vẽ lại toàn bộ tài liệu dùng CPU + GPU Memory (MB, 0 = Automatic) Bộ nhớ GPU (MB, 0 = Tự động) diff --git a/crates/ui-egui/src/i18n/zh-hans.tsv b/crates/ui-egui/src/i18n/zh-hans.tsv index 766497d67b..0186d4cf7e 100644 --- a/crates/ui-egui/src/i18n/zh-hans.tsv +++ b/crates/ui-egui/src/i18n/zh-hans.tsv @@ -2454,7 +2454,6 @@ cameraRaw Blending 混合 Redrawing 正在重绘 This redraw took {seconds} s on the CPU. 此次 CPU 重绘耗时 {seconds} 秒。 Edits that change the whole document, such as a layer's blend mode, opacity or visibility, redraw all of it. 更改整个文档的编辑(如图层的混合模式、不透明度或可见性)会重绘整个文档。 - Its layers don't fit the GPU memory budget. Raising Memory usage in Preferences › Performance raises the budget, up to a quarter of this computer's memory. 图层超出 GPU 内存预算。在 首选项 › 性能 中提高内存使用量可增加预算,最多为本机内存的四分之一。 The GPU compositor wasn't used: {reason} 未使用 GPU 合成器:{reason} fontWeight Thin 极细 fontWeight ExtraLight 特细 @@ -2816,3 +2815,12 @@ curves Smooth 平滑 # Free Transform: a new layer while the box is open (#5859) Free Transform is still being applied; try again when it has finished. 自由变换仍在应用中;请在完成后重试。 The transform couldn't be applied. 无法应用变换。 +# Preferences › Performance › GPU Memory; the status bar and slow-redraw notice + Its layers need {needed} of GPU memory; the budget leaves them {budget}. 其图层需要 {needed} GPU 内存;预算只留给它们 {budget}。 + Set GPU Memory in Preferences › Performance to what your graphics card can hold (Automatic follows Memory Usage, up to a quarter of this computer's memory). The status bar shows GPU Memory Full while they don't fit. 在 首选项 › 性能 中将 GPU 内存设置为显卡能容纳的大小(自动跟随内存使用量,最多为本机内存的四分之一)。在图层放不下期间,状态栏显示“GPU 内存已满”。 + GPU Memory Full GPU 内存已满 + The layers need {needed} of GPU memory and may hold {budget}, so edits that redraw the whole document do it on the CPU. Click to set GPU Memory. 图层需要 {needed} GPU 内存,但只能占用 {budget},因此重绘整个文档的编辑会在 CPU 上进行。点击以设置 GPU 内存。 + GPU memory budget: {budget} GPU 内存预算:{budget} + This document's layers: {needed} of {budget} for layers 此文档的图层:图层可用 {budget} 中的 {needed} + This document's layers need {needed} but may hold {budget}: whole-document redraws use the CPU 此文档的图层需要 {needed},但只能占用 {budget}:整个文档的重绘使用 CPU + GPU Memory (MB, 0 = Automatic) GPU 内存(MB,0 = 自动) diff --git a/crates/ui-egui/src/i18n/zh-hant.tsv b/crates/ui-egui/src/i18n/zh-hant.tsv index 3627c785e8..f87c71ccd9 100644 --- a/crates/ui-egui/src/i18n/zh-hant.tsv +++ b/crates/ui-egui/src/i18n/zh-hant.tsv @@ -2424,7 +2424,6 @@ cameraRaw Blending 混合 Redrawing 正在重繪 This redraw took {seconds} s on the CPU. 此次 CPU 重繪耗時 {seconds} 秒。 Edits that change the whole document, such as a layer's blend mode, opacity or visibility, redraw all of it. 變更整份文件的編輯(例如圖層的混合模式、不透明度或可見度)會重繪整份文件。 - Its layers don't fit the GPU memory budget. Raising Memory usage in Preferences › Performance raises the budget, up to a quarter of this computer's memory. 圖層超出 GPU 記憶體預算。在 偏好設定 › 效能 中提高記憶體使用量可增加預算,最多為本機記憶體的四分之一。 The GPU compositor wasn't used: {reason} 未使用 GPU 合成器:{reason} fontWeight Thin 極細 fontWeight ExtraLight 特細 @@ -2801,3 +2800,12 @@ curves Smooth 平滑 # Free Transform: a new layer while the box is open (#5859) Free Transform is still being applied; try again when it has finished. 任意變形仍在套用中;請在完成後再試一次。 The transform couldn't be applied. 無法套用變形。 +# Preferences › Performance › GPU Memory; the status bar and slow-redraw notice + Its layers need {needed} of GPU memory; the budget leaves them {budget}. 其圖層需要 {needed} GPU 記憶體;預算只留給它們 {budget}。 + Set GPU Memory in Preferences › Performance to what your graphics card can hold (Automatic follows Memory Usage, up to a quarter of this computer's memory). The status bar shows GPU Memory Full while they don't fit. 在 偏好設定 › 效能 中將 GPU 記憶體設為顯示卡能容納的大小(自動會跟隨記憶體使用量,最多為本機記憶體的四分之一)。在圖層放不下期間,狀態列會顯示「GPU 記憶體已滿」。 + GPU Memory Full GPU 記憶體已滿 + The layers need {needed} of GPU memory and may hold {budget}, so edits that redraw the whole document do it on the CPU. Click to set GPU Memory. 圖層需要 {needed} GPU 記憶體,但只能佔用 {budget},因此重繪整份文件的編輯會在 CPU 上進行。按一下以設定 GPU 記憶體。 + GPU memory budget: {budget} GPU 記憶體預算:{budget} + This document's layers: {needed} of {budget} for layers 此文件的圖層:圖層可用 {budget} 中的 {needed} + This document's layers need {needed} but may hold {budget}: whole-document redraws use the CPU 此文件的圖層需要 {needed},但只能佔用 {budget}:整份文件的重繪使用 CPU + GPU Memory (MB, 0 = Automatic) GPU 記憶體(MB,0 = 自動) diff --git a/crates/ui-egui/src/notices.rs b/crates/ui-egui/src/notices.rs index 146b8b8180..d1ed6d37cc 100644 --- a/crates/ui-egui/src/notices.rs +++ b/crates/ui-egui/src/notices.rs @@ -105,8 +105,14 @@ pub fn slow_cpu_refresh(app: &mut PhotocraftApp, doc: u64, ms: f64, reason: Opti tl!("Edits that change the whole document, such as a layer's blend mode, opacity or visibility, redraw all of it.").into(), ]; if reason == Some(crate::gpu_canvas::OVER_BUDGET) { + if let Some(fit) = app.perf.gpu_layer_fit.filter(|f| f.doc == doc && !f.fits()) { + lines.push(crate::i18n::fmt( + tl!("Its layers need {needed} of GPU memory; the budget leaves them {budget}."), + &[("needed", &crate::chrome_ui::fmt_bytes(fit.needed)), ("budget", &crate::chrome_ui::fmt_bytes(fit.budget))], + )); + } lines.push( - tl!("Its layers don't fit the GPU memory budget. Raising Memory usage in Preferences › Performance raises the budget, up to a quarter of this computer's memory.") + tl!("Set GPU Memory in Preferences › Performance to what your graphics card can hold (Automatic follows Memory Usage, up to a quarter of this computer's memory). The status bar shows GPU Memory Full while they don't fit.") .into(), ); } else if let Some(r) = reason { @@ -354,12 +360,15 @@ mod tests { slow_cpu_refresh(&mut app, 1, f64::NAN, Some(crate::gpu_canvas::OVER_BUDGET)); assert!(app.ui.notices.is_empty(), "a quick refresh needs no explanation"); + // The layers of document 1 need 3.6 GB and may hold 3.4 GB: the notice says so. + app.perf.gpu_layer_fit = Some(crate::gpu_canvas::LayerFit { doc: 1, needed: 3600 << 20, budget: 3400 << 20 }); slow_cpu_refresh(&mut app, 1, 55_000.0, Some(crate::gpu_canvas::OVER_BUDGET)); assert_eq!(app.ui.notices.len(), 1); let text = app.ui.notices[0].lines.join(" "); assert!(text.contains("55.0 s"), "{text}"); assert!(text.contains("blend mode, opacity or visibility"), "{text}"); - assert!(text.contains("Memory usage in Preferences › Performance"), "{text}"); + assert!(text.contains("need 3.5G of GPU memory; the budget leaves them 3.3G"), "{text}"); + assert!(text.contains("GPU Memory in Preferences › Performance"), "{text}"); assert!(!app.ui.notices[0].error); slow_cpu_refresh(&mut app, 1, 55_000.0, Some(crate::gpu_canvas::OVER_BUDGET)); diff --git a/crates/ui-egui/src/panels.rs b/crates/ui-egui/src/panels.rs index 73eb4c971f..4793554150 100644 --- a/crates/ui-egui/src/panels.rs +++ b/crates/ui-egui/src/panels.rs @@ -1613,6 +1613,7 @@ pub fn status_bar(app: &mut PhotocraftApp, ui: &mut egui::Ui) { ui.with_layout(crate::direction::trailing(), |ui| { crate::jobs_ui::status_progress(app, ui); crate::background_redraw::status(app, ui); + crate::chrome_ui::gpu_memory_status(app, ui); }); return; } @@ -1653,6 +1654,7 @@ pub fn status_bar(app: &mut PhotocraftApp, ui: &mut egui::Ui) { crate::jobs_ui::status_progress(app, ui); } crate::background_redraw::status(app, ui); + crate::chrome_ui::gpu_memory_status(app, ui); }); }); }); diff --git a/crates/ui-egui/src/prefs_ui.rs b/crates/ui-egui/src/prefs_ui.rs index b59e7803f5..e9282e44c1 100644 --- a/crates/ui-egui/src/prefs_ui.rs +++ b/crates/ui-egui/src/prefs_ui.rs @@ -1053,7 +1053,9 @@ pub fn open_mismatch(app: &mut PhotocraftApp, message: &str, action: &str, missi pub fn body(app: &mut PhotocraftApp, ui: &mut egui::Ui, f: &mut Map) { match f.get("__prefsui").and_then(Value::as_str).unwrap_or("") { "prefs" => { - f.insert("__gpuInfo".into(), json!(app.perf.gpu_info.lines())); + let mut info = app.perf.gpu_info.lines(); + info.extend(gpu_memory_lines(app)); + f.insert("__gpuInfo".into(), json!(info)); let system = system_theme(app, ui.ctx()); prefs_body(ui, f, system); } @@ -1065,6 +1067,27 @@ pub fn body(app: &mut PhotocraftApp, ui: &mut egui::Ui, f: &mut Map Vec { + let mut v = Vec::new(); + if app.perf.gpu_budget_mb > 0 { + let budget = crate::chrome_ui::fmt_bytes(app.perf.gpu_budget_mb.saturating_mul(1 << 20)); + v.push(crate::i18n::fmt(tl!("GPU memory budget: {budget}"), &[("budget", &budget)])); + } + let doc = app.session.active().map(|st| st.doc.id.0); + if let Some(fit) = app.perf.gpu_layer_fit.filter(|f| Some(f.doc) == doc) { + let args = [("needed", crate::chrome_ui::fmt_bytes(fit.needed)), ("budget", crate::chrome_ui::fmt_bytes(fit.budget))]; + let args: Vec<(&str, &str)> = args.iter().map(|(k, v)| (*k, v.as_str())).collect(); + v.push(if fit.fits() { + crate::i18n::fmt(tl!("This document's layers: {needed} of {budget} for layers"), &args) + } else { + crate::i18n::fmt(tl!("This document's layers need {needed} but may hold {budget}: whole-document redraws use the CPU"), &args) + }); + } + v +} + fn humanize(key: &str) -> String { match key { "appearanceMode" => return "Appearance Mode".into(), @@ -1099,6 +1122,10 @@ fn humanize(key: &str) -> String { if key == "lowLatencyDrawing" { return "Low-Latency Drawing (vsync off, applies at next launch)".into(); } + // Performance: the canvas compositor's GPU memory, 0 for the automatic budget (L3). + if key == "gpuMemoryMb" { + return "GPU Memory (MB, 0 = Automatic)".into(); + } let mut s = String::new(); for (i, ch) in key.chars().enumerate() { if i == 0 { diff --git a/docs/scorecard.md b/docs/scorecard.md index 8dbbea612e..377ce8583d 100644 --- a/docs/scorecard.md +++ b/docs/scorecard.md @@ -6,10 +6,10 @@ Generated by `cargo xtask scorecard` from `perf/budgets.toml`, `perf/baseline.js | Area | Done | Partial | Missing | Key numbers | |---|---:|---:|---:|---| -| [Performance](#performance) | 3 | 22 | 22 | 56 scenarios: 3 within budget, 22 over budget or failing, 9 tracked only, 17 not run, 5 not measurable yet (baseline 2026-10-05) | +| [Performance](#performance) | 3 | 22 | 24 | 58 scenarios: 3 within budget, 22 over budget or failing, 9 tracked only, 19 not run, 5 not measurable yet (baseline 2026-10-05) | | [File compatibility](#file-compatibility) | 7 | 14 | 6 | io corpus: oracle ≥ 148/170, round trip ≥ 169/170; psd-tools corpus: oracle ≥ 249/309, round trip ≥ 308/309; photoshop oracles: oracle ≥ 188/256, round trip ≥ 258/256 | | [Tools](#tools) | 33 | 11 | 0 | | -| [Workspace and UI](#workspace-and-ui) | 13 | 6 | 4 | settings that do nothing: 26 of 167 (target 0) | +| [Workspace and UI](#workspace-and-ui) | 13 | 6 | 4 | settings that do nothing: 26 of 168 (target 0) | | [Type](#type) | 4 | 3 | 2 | | | [Automation](#automation) | 5 | 2 | 4 | 631 of 631 menu items live (100.0%) | | [Reliability](#reliability) | 5 | 2 | 0 | never-crash lints on 29 of 29 crates; panic_hunt present | @@ -75,6 +75,8 @@ Baseline: 2026-10-05, commit `f5d2bcb54a`, Apple M4 Pro (48 GB), GPU Apple M4 Pr | P57 | Chisel bevel size change, 12 MP text paragraph, size 250 (GPU) | track only | – | – | – | – | – | not run | | | P58 | Chisel bevel size change, 12 MP ellipse, size 8 (CPU) | track only | – | – | – | – | – | not run | | | P59 | Chisel bevel size change, 12 MP text paragraph, size 8 (CPU) | track only | – | – | – | – | – | not run | | +| P60 | Layer opacity among 150 full-canvas layers, 6 MP (app GPU budget) | ≤ 30 ms p95 | – | – | – | – | – | not run | [#5973](https://github.com/storytold/photocraft/issues/5973) | +| P61 | Hide a layer among 150 full-canvas layers, 6 MP (app GPU budget) | ≤ 30 ms p95 | – | – | – | – | – | not run | [#5973](https://github.com/storytold/photocraft/issues/5973) | **Not measurable yet** (the code can't do it yet; no number is recorded): @@ -177,7 +179,7 @@ Corpus floors (`crates/io/tests/corpus.rs`): the enforced minimums; the corpus t ## Workspace and UI -**Settings that do nothing: 26 of 167** (target 0, #204). Counted by the prefs audit (`xtask/src/scorecard.rs`): a `Preferences` field counts as read when code outside the prefs model (`prefs.rs` except its `impl Session` blocks; tests excluded; the schema-driven dialog never names a field) accesses `section.field`, or `.section` and `.field` in the same file, or calls a section method that reads `self.field`. It is a heuristic lower bound: a generic name can look read when it isn't. +**Settings that do nothing: 26 of 168** (target 0, #204). Counted by the prefs audit (`xtask/src/scorecard.rs`): a `Preferences` field counts as read when code outside the prefs model (`prefs.rs` except its `impl Session` blocks; tests excluded; the schema-driven dialog never names a field) accesses `section.field`, or `.section` and `.field` in the same file, or calls a section method that reads `self.field`. It is a heuristic lower bound: a generic name can look read when it isn't. Unread: `general.color_picker`, `general.animated_zoom`, `general.zoom_resizes_windows`, `workspace.auto_show_hidden_panels`, `workspace.enable_floating_document_window_docking`, `file_handling.save_in_background`, `performance.cache_levels`, `performance.legacy_compositing`, `scratch_disks.disks`, `units_and_rulers.column_width`, `units_and_rulers.gutter`, `plug_ins.show_extension_panels`, `plug_ins.allow_scripts_to_connect`, `plug_ins.generator_enabled`, `type_.text_engine`, `type_.recent_fonts`, `enhanced_controls.scrubby_slider_acceleration`, `enhanced_controls.touch_gestures`, `raw_defaults.color_space`, `raw_defaults.bit_depth`, `raw_defaults.resolution`, `raw_defaults.sharpen_for`, `raw_defaults.open_as_smart_object`, `raw_defaults.apply_auto_tone`, `integrations.allow_agent_control`, `integrations.control_port`. diff --git a/perf/budgets.toml b/perf/budgets.toml index a95c1c83dc..da47aa0503 100644 --- a/perf/budgets.toml +++ b/perf/budgets.toml @@ -518,3 +518,21 @@ id = "P59" title = "Chisel bevel size change, 12 MP text paragraph, size 8 (CPU)" bench = "bevel_bench" row = "bevel size change to 8, text (CPU)" + +[[scenario]] +id = "P60" +title = "Layer opacity among 150 full-canvas layers, 6 MP (app GPU budget)" +issue = 5973 +bench = "perf_scenarios" +row = "opacity of a layer among many full-canvas layers + refresh" +budget_ms = 30 +enforce = false # new; no baseline yet + +[[scenario]] +id = "P61" +title = "Hide a layer among 150 full-canvas layers, 6 MP (app GPU budget)" +issue = 5973 +bench = "perf_scenarios" +row = "hide a layer among many full-canvas layers + refresh" +budget_ms = 30 +enforce = false # new; no baseline yet From 6368e6a851da8836a1f0ac978d171b7d0a7643d6 Mon Sep 17 00:00:00 2001 From: Tanish Manku Date: Thu, 15 Oct 2026 21:37:05 -0700 Subject: [PATCH 44/47] GPU memory follow-ups (#5973 review): fit cleared on device loss and on the CPU canvas; measured when switching to a document whose canvas is current; notice names the 3/8 card cap; VRAM override documented; perf rows count memory like the app (#6458) --- crates/ui-egui/examples/perf_scenarios.rs | 5 +++-- crates/ui-egui/src/canvas.rs | 12 ++++++++++++ crates/ui-egui/src/gpu_canvas.rs | 3 ++- crates/ui-egui/src/gpu_status.rs | 2 ++ crates/ui-egui/src/i18n/cs.tsv | 2 +- crates/ui-egui/src/i18n/de.tsv | 2 +- crates/ui-egui/src/i18n/el.tsv | 2 +- crates/ui-egui/src/i18n/es.tsv | 2 +- crates/ui-egui/src/i18n/fr.tsv | 2 +- crates/ui-egui/src/i18n/hu.tsv | 2 +- crates/ui-egui/src/i18n/id.tsv | 2 +- crates/ui-egui/src/i18n/it.tsv | 2 +- crates/ui-egui/src/i18n/ja.tsv | 2 +- crates/ui-egui/src/i18n/ko.tsv | 2 +- crates/ui-egui/src/i18n/nl.tsv | 2 +- crates/ui-egui/src/i18n/pl.tsv | 2 +- crates/ui-egui/src/i18n/pt-br.tsv | 2 +- crates/ui-egui/src/i18n/ru.tsv | 2 +- crates/ui-egui/src/i18n/sr.tsv | 2 +- crates/ui-egui/src/i18n/tr.tsv | 2 +- crates/ui-egui/src/i18n/uk.tsv | 2 +- crates/ui-egui/src/i18n/vi.tsv | 2 +- crates/ui-egui/src/i18n/zh-hans.tsv | 2 +- crates/ui-egui/src/i18n/zh-hant.tsv | 2 +- crates/ui-egui/src/notices.rs | 2 +- 25 files changed, 40 insertions(+), 24 deletions(-) diff --git a/crates/ui-egui/examples/perf_scenarios.rs b/crates/ui-egui/examples/perf_scenarios.rs index 969d9533c1..72808cfabc 100644 --- a/crates/ui-egui/examples/perf_scenarios.rs +++ b/crates/ui-egui/examples/perf_scenarios.rs @@ -543,7 +543,6 @@ fn layered_scenarios(b: &mut Bench, sz: &Sizes) { }); } -/// #209: arrow-key nudge in the 150-layer document; #1771: a new layer there. /// Layers overhaul L3: edits that redraw the whole document (a layer's opacity, hiding it) with /// many full-canvas layers, under the app's own GPU memory budget (Memory Usage at its default, /// the adapter's kind; `gpu_canvas::memory_budget`). Past what the budget holds, each one went to @@ -580,7 +579,8 @@ fn full_canvas_layer_scenarios(b: &mut Bench, sz: &Sizes) { } }; if let (Some((g, _)), Some(st)) = (&b.gpu, s.active()) { - let pixels = st.history.pixel_bytes(&st.doc) as u64; + // As the app counts it (`canvas::gpu_budget`). + let pixels = st.memory_bytes() as u64; let allowance = u64::from(photocraft_engine::prefs::Performance::default().memory_usage_mb) << 20; g.set_memory_budget(photocraft_ui_egui::gpu_canvas::memory_budget( allowance, @@ -604,6 +604,7 @@ fn full_canvas_layer_scenarios(b: &mut Bench, sz: &Sizes) { }); } +/// #209: arrow-key nudge in the 150-layer document; #1771: a new layer there. fn many_layer_scenarios(b: &mut Bench, sz: &Sizes) { // Row names stay stable across modes: the layer count is in the mode, not the key. let name = "nudge 1 px in the many-layer document + refresh".to_string(); diff --git a/crates/ui-egui/src/canvas.rs b/crates/ui-egui/src/canvas.rs index 1ee7f02db9..57d3575111 100644 --- a/crates/ui-egui/src/canvas.rs +++ b/crates/ui-egui/src/canvas.rs @@ -1621,6 +1621,14 @@ fn ensure_gpu(app: &mut PhotocraftApp, ctx: &egui::Context, idx: usize, visible: if present && app.perf.gpu_budget_allowance != budget_allowance(app) { gpu_budget(app, &gpu, idx, false, visible); } + // Switched to a document whose canvas is already current: size the budget for it and measure + // whether its layers fit, so GPU Memory Full shows (or clears) at once rather than at its + // next full refresh. Once per switch: the fit then names this document. + if present && app.perf.gpu_layer_fit.is_none_or(|f| f.doc != id.0) { + gpu_budget(app, &gpu, idx, false, visible); + let fit = gpu.measure_fit(&doc).unwrap_or(crate::gpu_canvas::LayerFit { doc: id.0, needed: 0, budget: 0 }); + app.perf.gpu_layer_fit = Some(fit); + } if present && current { // An adjustment preview composited where the view was: catch up where it moved to. if let Some(r) = crate::adjust_preview::uncovered(app, id, raw_key, visible) { @@ -1633,6 +1641,10 @@ fn ensure_gpu(app: &mut PhotocraftApp, ctx: &egui::Context, idx: usize, visible: // draws it (checked before the memory budget, which walks the history, every frame). if !gpu.fits_canvas(&doc) { crate::background_redraw::cancel(app, id); + // Drawn on the CPU canvas: no layer budget applies to it. + if app.perf.gpu_layer_fit.is_some_and(|f| f.doc == id.0) { + app.perf.gpu_layer_fit = None; + } return false; } let partial = present && damage.is_some(); diff --git a/crates/ui-egui/src/gpu_canvas.rs b/crates/ui-egui/src/gpu_canvas.rs index 8ef3973305..012bcf801c 100644 --- a/crates/ui-egui/src/gpu_canvas.rs +++ b/crates/ui-egui/src/gpu_canvas.rs @@ -1379,7 +1379,8 @@ fn detect_physical_memory() -> Option { /// it: DXGI's dedicated video memory on Windows, amdgpu's VRAM size on Linux. `None` for /// integrated and software adapters, whose textures live in system memory (the RAM-based /// budgets already cover them), and where nothing reports it. `PHOTOCRAFT_VRAM_MB` overrides it -/// for any adapter (to simulate a smaller card). +/// for any adapter, simulating a discrete card of that size: on an integrated GPU the budget then +/// stops charging the document's pixels too, as for a real card (a development switch). pub fn video_memory(adapter: &wgpu::AdapterInfo) -> Option { if let Some(mb) = std::env::var("PHOTOCRAFT_VRAM_MB").ok().and_then(|v| v.trim().parse::().ok()) { return mb.checked_mul(1 << 20).filter(|b| *b > 0); diff --git a/crates/ui-egui/src/gpu_status.rs b/crates/ui-egui/src/gpu_status.rs index bbbc997fc1..337283bdf8 100644 --- a/crates/ui-egui/src/gpu_status.rs +++ b/crates/ui-egui/src/gpu_status.rs @@ -50,6 +50,8 @@ pub fn fall_back(app: &mut PhotocraftApp, fault: &photocraft_gpu::Fault) { app.prefs_rt.gpu_style = None; app.perf.gpu = false; app.perf.gpu_fallback = Some(fault.to_string()); + // The CPU canvas has no GPU memory budget: GPU Memory Full no longer applies. + app.perf.gpu_layer_fit = None; app.perf.gpu_info.canvas = "cpu".into(); app.perf.gpu_info.lost = Some(fault.to_string()); let title = if fault.is_lost() { LOST_MESSAGE } else { ERROR_MESSAGE }; diff --git a/crates/ui-egui/src/i18n/cs.tsv b/crates/ui-egui/src/i18n/cs.tsv index e9cc357fc9..f3fb8c8c40 100644 --- a/crates/ui-egui/src/i18n/cs.tsv +++ b/crates/ui-egui/src/i18n/cs.tsv @@ -3809,7 +3809,7 @@ navigator Zoom Zvětšení The transform couldn't be applied. Transformaci nelze použít. # Preferences › Performance › GPU Memory; the status bar and slow-redraw notice Its layers need {needed} of GPU memory; the budget leaves them {budget}. Vrstvy potřebují {needed} paměti GPU; rozpočet jim ponechává {budget}. - Set GPU Memory in Preferences › Performance to what your graphics card can hold (Automatic follows Memory Usage, up to a quarter of this computer's memory). The status bar shows GPU Memory Full while they don't fit. V Předvolbách › Výkon nastavte Paměť GPU na tolik, kolik vaše grafická karta pojme (Automaticky se řídí Využitím paměti, nejvýše čtvrtinou paměti tohoto počítače). Dokud se nevejdou, stavový řádek ukazuje Paměť GPU je plná. + Set GPU Memory in Preferences › Performance to what your graphics card can hold, at most 3/8 of its memory. Automatic follows Memory Usage, within a quarter of this computer's memory and 3/8 of the card's, and doesn't count the document against a card whose memory is known. The status bar shows GPU Memory Full while they don't fit. V Předvolbách › Výkon nastavte Paměť GPU na tolik, kolik vaše grafická karta pojme, nejvýše 3/8 její paměti. Automaticky se řídí Využitím paměti, v rámci čtvrtiny paměti tohoto počítače a 3/8 paměti karty, a u karty se známou pamětí do ní dokument nezapočítává. Dokud se nevejdou, stavový řádek ukazuje Paměť GPU je plná. GPU Memory Full Paměť GPU je plná The layers need {needed} of GPU memory and may hold {budget}, so edits that redraw the whole document do it on the CPU. Click to set GPU Memory. Vrstvy potřebují {needed} paměti GPU a mohou zabrat {budget}, takže úpravy, které překreslují celý dokument, to dělají na CPU. Kliknutím nastavíte Paměť GPU. GPU memory budget: {budget} Rozpočet paměti GPU: {budget} diff --git a/crates/ui-egui/src/i18n/de.tsv b/crates/ui-egui/src/i18n/de.tsv index 23374a2993..48ebfba678 100644 --- a/crates/ui-egui/src/i18n/de.tsv +++ b/crates/ui-egui/src/i18n/de.tsv @@ -3203,7 +3203,7 @@ curves Smooth Glätten The transform couldn't be applied. Die Transformation konnte nicht angewendet werden. # Preferences › Performance › GPU Memory; the status bar and slow-redraw notice Its layers need {needed} of GPU memory; the budget leaves them {budget}. Die Ebenen brauchen {needed} GPU-Speicher; das Budget lässt ihnen {budget}. - Set GPU Memory in Preferences › Performance to what your graphics card can hold (Automatic follows Memory Usage, up to a quarter of this computer's memory). The status bar shows GPU Memory Full while they don't fit. Stellen Sie unter Voreinstellungen › Leistung den GPU-Speicher auf das ein, was Ihre Grafikkarte fassen kann (Automatisch folgt der Speichernutzung, bis zu einem Viertel des Arbeitsspeichers dieses Computers). Solange sie nicht hineinpassen, zeigt die Statusleiste GPU-Speicher voll. + Set GPU Memory in Preferences › Performance to what your graphics card can hold, at most 3/8 of its memory. Automatic follows Memory Usage, within a quarter of this computer's memory and 3/8 of the card's, and doesn't count the document against a card whose memory is known. The status bar shows GPU Memory Full while they don't fit. Stellen Sie unter Voreinstellungen › Leistung den GPU-Speicher auf das ein, was Ihre Grafikkarte fassen kann, höchstens 3/8 ihres Speichers. Automatisch folgt der Speichernutzung, innerhalb eines Viertels des Arbeitsspeichers dieses Computers und 3/8 des Kartenspeichers, und rechnet das Dokument bei einer Karte mit bekanntem Speicher nicht an. Solange sie nicht hineinpassen, zeigt die Statusleiste GPU-Speicher voll. GPU Memory Full GPU-Speicher voll The layers need {needed} of GPU memory and may hold {budget}, so edits that redraw the whole document do it on the CPU. Click to set GPU Memory. Die Ebenen brauchen {needed} GPU-Speicher und dürfen {budget} belegen, daher zeichnen Änderungen, die das ganze Dokument neu zeichnen, auf der CPU. Klicken, um den GPU-Speicher einzustellen. GPU memory budget: {budget} GPU-Speicherbudget: {budget} diff --git a/crates/ui-egui/src/i18n/el.tsv b/crates/ui-egui/src/i18n/el.tsv index f3743db5c8..f2c67c2c41 100644 --- a/crates/ui-egui/src/i18n/el.tsv +++ b/crates/ui-egui/src/i18n/el.tsv @@ -3901,7 +3901,7 @@ curves Smooth Εξομάλυνση The transform couldn't be applied. Δεν ήταν δυνατή η εφαρμογή του μετασχηματισμού. # Preferences › Performance › GPU Memory; the status bar and slow-redraw notice Its layers need {needed} of GPU memory; the budget leaves them {budget}. Τα επίπεδά του χρειάζονται {needed} μνήμης GPU· το όριο τους αφήνει {budget}. - Set GPU Memory in Preferences › Performance to what your graphics card can hold (Automatic follows Memory Usage, up to a quarter of this computer's memory). The status bar shows GPU Memory Full while they don't fit. Ορίστε τη Μνήμη GPU στις Προτιμήσεις › Επιδόσεις σε όσο χωράει η κάρτα γραφικών σας (το Αυτόματο ακολουθεί τη Χρήση μνήμης, έως το ένα τέταρτο της μνήμης αυτού του υπολογιστή). Όσο δεν χωρούν, η γραμμή κατάστασης δείχνει Η μνήμη GPU είναι πλήρης. + Set GPU Memory in Preferences › Performance to what your graphics card can hold, at most 3/8 of its memory. Automatic follows Memory Usage, within a quarter of this computer's memory and 3/8 of the card's, and doesn't count the document against a card whose memory is known. The status bar shows GPU Memory Full while they don't fit. Ορίστε τη Μνήμη GPU στις Προτιμήσεις › Επιδόσεις σε όσο χωράει η κάρτα γραφικών σας, το πολύ στα 3/8 της μνήμης της. Το Αυτόματο ακολουθεί τη Χρήση μνήμης, εντός του ενός τετάρτου της μνήμης αυτού του υπολογιστή και των 3/8 της κάρτας, και δεν χρεώνει το έγγραφο σε κάρτα με γνωστή μνήμη. Όσο δεν χωρούν, η γραμμή κατάστασης δείχνει Η μνήμη GPU είναι πλήρης. GPU Memory Full Η μνήμη GPU είναι πλήρης The layers need {needed} of GPU memory and may hold {budget}, so edits that redraw the whole document do it on the CPU. Click to set GPU Memory. Τα επίπεδα χρειάζονται {needed} μνήμης GPU και μπορούν να καταλάβουν {budget}, οπότε οι αλλαγές που ξανασχεδιάζουν όλο το έγγραφο το κάνουν στη CPU. Κάντε κλικ για να ορίσετε τη Μνήμη GPU. GPU memory budget: {budget} Όριο μνήμης GPU: {budget} diff --git a/crates/ui-egui/src/i18n/es.tsv b/crates/ui-egui/src/i18n/es.tsv index 3d00c696de..45804fa2a4 100644 --- a/crates/ui-egui/src/i18n/es.tsv +++ b/crates/ui-egui/src/i18n/es.tsv @@ -4163,7 +4163,7 @@ navigator Zoom Zoom The transform couldn't be applied. No se pudo aplicar la transformación. # Preferences › Performance › GPU Memory; the status bar and slow-redraw notice Its layers need {needed} of GPU memory; the budget leaves them {budget}. Sus capas necesitan {needed} de memoria de GPU; el presupuesto les deja {budget}. - Set GPU Memory in Preferences › Performance to what your graphics card can hold (Automatic follows Memory Usage, up to a quarter of this computer's memory). The status bar shows GPU Memory Full while they don't fit. Ajuste Memoria de GPU en Preferencias › Rendimiento a lo que pueda alojar su tarjeta gráfica (Automático sigue Uso de memoria, hasta una cuarta parte de la memoria de este equipo). Mientras no quepan, la barra de estado muestra Memoria de GPU llena. + Set GPU Memory in Preferences › Performance to what your graphics card can hold, at most 3/8 of its memory. Automatic follows Memory Usage, within a quarter of this computer's memory and 3/8 of the card's, and doesn't count the document against a card whose memory is known. The status bar shows GPU Memory Full while they don't fit. Ajuste Memoria de GPU en Preferencias › Rendimiento a lo que pueda alojar su tarjeta gráfica, como máximo 3/8 de su memoria. Automático sigue Uso de memoria, dentro de una cuarta parte de la memoria de este equipo y 3/8 de la de la tarjeta, y no descuenta el documento en una tarjeta cuya memoria se conoce. Mientras no quepan, la barra de estado muestra Memoria de GPU llena. GPU Memory Full Memoria de GPU llena The layers need {needed} of GPU memory and may hold {budget}, so edits that redraw the whole document do it on the CPU. Click to set GPU Memory. Las capas necesitan {needed} de memoria de GPU y pueden ocupar {budget}, así que las ediciones que redibujan todo el documento lo hacen en la CPU. Haga clic para ajustar Memoria de GPU. GPU memory budget: {budget} Presupuesto de memoria de GPU: {budget} diff --git a/crates/ui-egui/src/i18n/fr.tsv b/crates/ui-egui/src/i18n/fr.tsv index 2aa12b16e6..3e3451e096 100644 --- a/crates/ui-egui/src/i18n/fr.tsv +++ b/crates/ui-egui/src/i18n/fr.tsv @@ -3206,7 +3206,7 @@ curves Smooth Lisser The transform couldn't be applied. Impossible d'appliquer la transformation. # Preferences › Performance › GPU Memory; the status bar and slow-redraw notice Its layers need {needed} of GPU memory; the budget leaves them {budget}. Ses calques ont besoin de {needed} de mémoire GPU ; le budget leur laisse {budget}. - Set GPU Memory in Preferences › Performance to what your graphics card can hold (Automatic follows Memory Usage, up to a quarter of this computer's memory). The status bar shows GPU Memory Full while they don't fit. Réglez Mémoire GPU dans Préférences › Performances sur ce que votre carte graphique peut contenir (Automatique suit Utilisation de la mémoire, jusqu'au quart de la mémoire de cet ordinateur). Tant qu'ils ne tiennent pas, la barre d'état affiche Mémoire GPU pleine. + Set GPU Memory in Preferences › Performance to what your graphics card can hold, at most 3/8 of its memory. Automatic follows Memory Usage, within a quarter of this computer's memory and 3/8 of the card's, and doesn't count the document against a card whose memory is known. The status bar shows GPU Memory Full while they don't fit. Réglez Mémoire GPU dans Préférences › Performances sur ce que votre carte graphique peut contenir, au plus 3/8 de sa mémoire. Automatique suit Utilisation de la mémoire, dans la limite du quart de la mémoire de cet ordinateur et des 3/8 de celle de la carte, et ne compte pas le document sur une carte dont la mémoire est connue. Tant qu'ils ne tiennent pas, la barre d'état affiche Mémoire GPU pleine. GPU Memory Full Mémoire GPU pleine The layers need {needed} of GPU memory and may hold {budget}, so edits that redraw the whole document do it on the CPU. Click to set GPU Memory. Les calques ont besoin de {needed} de mémoire GPU et peuvent en occuper {budget} : les modifications qui redessinent tout le document le font sur le CPU. Cliquez pour régler Mémoire GPU. GPU memory budget: {budget} Budget de mémoire GPU : {budget} diff --git a/crates/ui-egui/src/i18n/hu.tsv b/crates/ui-egui/src/i18n/hu.tsv index 6d9eff4751..9dc9a47fbc 100644 --- a/crates/ui-egui/src/i18n/hu.tsv +++ b/crates/ui-egui/src/i18n/hu.tsv @@ -4120,7 +4120,7 @@ cameraRaw Yellow Sárga The transform couldn't be applied. Az átalakítás nem alkalmazható. # Preferences › Performance › GPU Memory; the status bar and slow-redraw notice Its layers need {needed} of GPU memory; the budget leaves them {budget}. A rétegeknek {needed} GPU-memória kell; a keret {budget} hagy nekik. - Set GPU Memory in Preferences › Performance to what your graphics card can hold (Automatic follows Memory Usage, up to a quarter of this computer's memory). The status bar shows GPU Memory Full while they don't fit. A Beállítások › Teljesítmény alatt állítsd a GPU-memóriát arra, amennyit a videokártyád elbír (az Automatikus a memóriahasználatot követi, legfeljebb a számítógép memóriájának negyedéig). Amíg nem férnek el, az állapotsor a GPU-memória megtelt üzenetet mutatja. + Set GPU Memory in Preferences › Performance to what your graphics card can hold, at most 3/8 of its memory. Automatic follows Memory Usage, within a quarter of this computer's memory and 3/8 of the card's, and doesn't count the document against a card whose memory is known. The status bar shows GPU Memory Full while they don't fit. A Beállítások › Teljesítmény alatt állítsd a GPU-memóriát arra, amennyit a videokártyád elbír, legfeljebb a memóriája 3/8-ára. Az Automatikus a memóriahasználatot követi, a számítógép memóriájának negyedén és a kártya 3/8-án belül, és ismert memóriájú kártyánál nem számítja bele a dokumentumot. Amíg nem férnek el, az állapotsor a GPU-memória megtelt üzenetet mutatja. GPU Memory Full GPU-memória megtelt The layers need {needed} of GPU memory and may hold {budget}, so edits that redraw the whole document do it on the CPU. Click to set GPU Memory. A rétegeknek {needed} GPU-memória kell, és {budget} foglalhatnak, ezért a teljes dokumentumot újrarajzoló szerkesztések a CPU-n futnak. Kattints a GPU-memória beállításához. GPU memory budget: {budget} GPU-memóriakeret: {budget} diff --git a/crates/ui-egui/src/i18n/id.tsv b/crates/ui-egui/src/i18n/id.tsv index dd972b82d8..81e850f293 100644 --- a/crates/ui-egui/src/i18n/id.tsv +++ b/crates/ui-egui/src/i18n/id.tsv @@ -3193,7 +3193,7 @@ curves Smooth Haluskan The transform couldn't be applied. Transformasi tidak dapat diterapkan. # Preferences › Performance › GPU Memory; the status bar and slow-redraw notice Its layers need {needed} of GPU memory; the budget leaves them {budget}. Layer-nya membutuhkan {needed} memori GPU; anggaran menyisakan {budget}. - Set GPU Memory in Preferences › Performance to what your graphics card can hold (Automatic follows Memory Usage, up to a quarter of this computer's memory). The status bar shows GPU Memory Full while they don't fit. Atur Memori GPU di Preferensi › Performa sesuai kapasitas kartu grafis Anda (Otomatis mengikuti Pemakaian memori, hingga seperempat memori komputer ini). Selama belum muat, bilah status menampilkan Memori GPU Penuh. + Set GPU Memory in Preferences › Performance to what your graphics card can hold, at most 3/8 of its memory. Automatic follows Memory Usage, within a quarter of this computer's memory and 3/8 of the card's, and doesn't count the document against a card whose memory is known. The status bar shows GPU Memory Full while they don't fit. Atur Memori GPU di Preferensi › Performa sesuai kapasitas kartu grafis Anda, paling banyak 3/8 memorinya. Otomatis mengikuti Pemakaian memori, dalam seperempat memori komputer ini dan 3/8 memori kartu, dan tidak membebankan dokumen pada kartu yang memorinya diketahui. Selama belum muat, bilah status menampilkan Memori GPU Penuh. GPU Memory Full Memori GPU Penuh The layers need {needed} of GPU memory and may hold {budget}, so edits that redraw the whole document do it on the CPU. Click to set GPU Memory. Layer membutuhkan {needed} memori GPU dan boleh memakai {budget}, sehingga suntingan yang menggambar ulang seluruh dokumen melakukannya di CPU. Klik untuk mengatur Memori GPU. GPU memory budget: {budget} Anggaran memori GPU: {budget} diff --git a/crates/ui-egui/src/i18n/it.tsv b/crates/ui-egui/src/i18n/it.tsv index 1b76fdee65..c097ded29d 100644 --- a/crates/ui-egui/src/i18n/it.tsv +++ b/crates/ui-egui/src/i18n/it.tsv @@ -4322,7 +4322,7 @@ shape Appearance Aspetto The transform couldn't be applied. Impossibile applicare la trasformazione. # Preferences › Performance › GPU Memory; the status bar and slow-redraw notice Its layers need {needed} of GPU memory; the budget leaves them {budget}. I livelli richiedono {needed} di memoria GPU; il budget ne lascia {budget}. - Set GPU Memory in Preferences › Performance to what your graphics card can hold (Automatic follows Memory Usage, up to a quarter of this computer's memory). The status bar shows GPU Memory Full while they don't fit. Imposta Memoria GPU in Preferenze › Prestazioni su quanto può contenere la tua scheda grafica (Automatico segue Uso memoria, fino a un quarto della memoria di questo computer). Finché non ci stanno, la barra di stato mostra Memoria GPU piena. + Set GPU Memory in Preferences › Performance to what your graphics card can hold, at most 3/8 of its memory. Automatic follows Memory Usage, within a quarter of this computer's memory and 3/8 of the card's, and doesn't count the document against a card whose memory is known. The status bar shows GPU Memory Full while they don't fit. Imposta Memoria GPU in Preferenze › Prestazioni su quanto può contenere la tua scheda grafica, al massimo 3/8 della sua memoria. Automatico segue Uso memoria, entro un quarto della memoria di questo computer e 3/8 di quella della scheda, e non conta il documento su una scheda la cui memoria è nota. Finché non ci stanno, la barra di stato mostra Memoria GPU piena. GPU Memory Full Memoria GPU piena The layers need {needed} of GPU memory and may hold {budget}, so edits that redraw the whole document do it on the CPU. Click to set GPU Memory. I livelli richiedono {needed} di memoria GPU e possono occuparne {budget}, quindi le modifiche che ridisegnano tutto il documento lo fanno sulla CPU. Fai clic per impostare Memoria GPU. GPU memory budget: {budget} Budget di memoria GPU: {budget} diff --git a/crates/ui-egui/src/i18n/ja.tsv b/crates/ui-egui/src/i18n/ja.tsv index 2b064edcdf..b460965128 100644 --- a/crates/ui-egui/src/i18n/ja.tsv +++ b/crates/ui-egui/src/i18n/ja.tsv @@ -3877,7 +3877,7 @@ curves Smooth スムーズ The transform couldn't be applied. 変形を適用できませんでした。 # Preferences › Performance › GPU Memory; the status bar and slow-redraw notice Its layers need {needed} of GPU memory; the budget leaves them {budget}. レイヤーには {needed} の GPU メモリが必要ですが、予算で使えるのは {budget} です。 - Set GPU Memory in Preferences › Performance to what your graphics card can hold (Automatic follows Memory Usage, up to a quarter of this computer's memory). The status bar shows GPU Memory Full while they don't fit. 環境設定 › パフォーマンスの GPU メモリを、グラフィックカードが保持できる量に設定してください(自動はメモリ使用量に従い、このコンピューターのメモリの4分の1までです)。収まらない間、ステータスバーに「GPU メモリ不足」と表示されます。 + Set GPU Memory in Preferences › Performance to what your graphics card can hold, at most 3/8 of its memory. Automatic follows Memory Usage, within a quarter of this computer's memory and 3/8 of the card's, and doesn't count the document against a card whose memory is known. The status bar shows GPU Memory Full while they don't fit. 環境設定 › パフォーマンスの GPU メモリを、グラフィックカードが保持できる量(最大でメモリの 3/8)に設定してください。自動はメモリ使用量に従い、このコンピューターのメモリの4分の1とカードの 3/8 の範囲内で、メモリ量がわかるカードではドキュメント分を差し引きません。収まらない間、ステータスバーに「GPU メモリ不足」と表示されます。 GPU Memory Full GPU メモリ不足 The layers need {needed} of GPU memory and may hold {budget}, so edits that redraw the whole document do it on the CPU. Click to set GPU Memory. レイヤーには {needed} の GPU メモリが必要ですが、使えるのは {budget} のため、ドキュメント全体を再描画する編集は CPU で行われます。クリックして GPU メモリを設定します。 GPU memory budget: {budget} GPU メモリ予算: {budget} diff --git a/crates/ui-egui/src/i18n/ko.tsv b/crates/ui-egui/src/i18n/ko.tsv index 5248924d6e..361cef2e95 100644 --- a/crates/ui-egui/src/i18n/ko.tsv +++ b/crates/ui-egui/src/i18n/ko.tsv @@ -3379,7 +3379,7 @@ curves Smooth 매끄럽게 The transform couldn't be applied. 변형을 적용할 수 없습니다. # Preferences › Performance › GPU Memory; the status bar and slow-redraw notice Its layers need {needed} of GPU memory; the budget leaves them {budget}. 레이어에 GPU 메모리 {needed}가 필요하지만 예산은 {budget}만 남겨 둡니다. - Set GPU Memory in Preferences › Performance to what your graphics card can hold (Automatic follows Memory Usage, up to a quarter of this computer's memory). The status bar shows GPU Memory Full while they don't fit. 환경 설정 › 성능에서 GPU 메모리를 그래픽 카드가 감당할 수 있는 양으로 설정하세요(자동은 메모리 사용량을 따르며 이 컴퓨터 메모리의 4분의 1까지입니다). 맞지 않는 동안 상태 표시줄에 GPU 메모리 부족이 표시됩니다. + Set GPU Memory in Preferences › Performance to what your graphics card can hold, at most 3/8 of its memory. Automatic follows Memory Usage, within a quarter of this computer's memory and 3/8 of the card's, and doesn't count the document against a card whose memory is known. The status bar shows GPU Memory Full while they don't fit. 환경 설정 › 성능에서 GPU 메모리를 그래픽 카드가 감당할 수 있는 양으로 설정하세요(최대 카드 메모리의 3/8). 자동은 메모리 사용량을 따르며, 이 컴퓨터 메모리의 4분의 1과 카드 메모리의 3/8 이내이고, 메모리를 알 수 있는 카드에서는 문서를 차감하지 않습니다. 맞지 않는 동안 상태 표시줄에 GPU 메모리 부족이 표시됩니다. GPU Memory Full GPU 메모리 부족 The layers need {needed} of GPU memory and may hold {budget}, so edits that redraw the whole document do it on the CPU. Click to set GPU Memory. 레이어에 GPU 메모리 {needed}가 필요하지만 {budget}만 사용할 수 있어 문서 전체를 다시 그리는 편집은 CPU에서 처리됩니다. 클릭하여 GPU 메모리를 설정합니다. GPU memory budget: {budget} GPU 메모리 예산: {budget} diff --git a/crates/ui-egui/src/i18n/nl.tsv b/crates/ui-egui/src/i18n/nl.tsv index efb03ed9f4..f1ac67961e 100644 --- a/crates/ui-egui/src/i18n/nl.tsv +++ b/crates/ui-egui/src/i18n/nl.tsv @@ -3961,7 +3961,7 @@ curves Smooth Vloeiend maken The transform couldn't be applied. De transformatie kon niet worden toegepast. # Preferences › Performance › GPU Memory; the status bar and slow-redraw notice Its layers need {needed} of GPU memory; the budget leaves them {budget}. De lagen hebben {needed} GPU-geheugen nodig; het budget laat ze {budget}. - Set GPU Memory in Preferences › Performance to what your graphics card can hold (Automatic follows Memory Usage, up to a quarter of this computer's memory). The status bar shows GPU Memory Full while they don't fit. Stel GPU-geheugen in bij Voorkeuren › Prestaties op wat uw videokaart aankan (Automatisch volgt Geheugengebruik, tot een kwart van het geheugen van deze computer). Zolang ze niet passen, toont de statusbalk GPU-geheugen vol. + Set GPU Memory in Preferences › Performance to what your graphics card can hold, at most 3/8 of its memory. Automatic follows Memory Usage, within a quarter of this computer's memory and 3/8 of the card's, and doesn't count the document against a card whose memory is known. The status bar shows GPU Memory Full while they don't fit. Stel GPU-geheugen in bij Voorkeuren › Prestaties op wat uw videokaart aankan, hooguit 3/8 van haar geheugen. Automatisch volgt Geheugengebruik, binnen een kwart van het geheugen van deze computer en 3/8 van dat van de kaart, en rekent het document niet af van een kaart waarvan het geheugen bekend is. Zolang ze niet passen, toont de statusbalk GPU-geheugen vol. GPU Memory Full GPU-geheugen vol The layers need {needed} of GPU memory and may hold {budget}, so edits that redraw the whole document do it on the CPU. Click to set GPU Memory. De lagen hebben {needed} GPU-geheugen nodig en mogen {budget} gebruiken, dus bewerkingen die het hele document opnieuw tekenen doen dat op de CPU. Klik om GPU-geheugen in te stellen. GPU memory budget: {budget} GPU-geheugenbudget: {budget} diff --git a/crates/ui-egui/src/i18n/pl.tsv b/crates/ui-egui/src/i18n/pl.tsv index 31ed39fab3..04eb15609d 100644 --- a/crates/ui-egui/src/i18n/pl.tsv +++ b/crates/ui-egui/src/i18n/pl.tsv @@ -3799,7 +3799,7 @@ curves Smooth Wygładź The transform couldn't be applied. Nie można zastosować przekształcenia. # Preferences › Performance › GPU Memory; the status bar and slow-redraw notice Its layers need {needed} of GPU memory; the budget leaves them {budget}. Warstwy potrzebują {needed} pamięci GPU; budżet zostawia im {budget}. - Set GPU Memory in Preferences › Performance to what your graphics card can hold (Automatic follows Memory Usage, up to a quarter of this computer's memory). The status bar shows GPU Memory Full while they don't fit. Ustaw Pamięć GPU w Preferencje › Wydajność na tyle, ile zmieści Twoja karta graficzna (Automatycznie podąża za Użyciem pamięci, maksymalnie do jednej czwartej pamięci tego komputera). Dopóki się nie mieszczą, pasek stanu pokazuje Pamięć GPU pełna. + Set GPU Memory in Preferences › Performance to what your graphics card can hold, at most 3/8 of its memory. Automatic follows Memory Usage, within a quarter of this computer's memory and 3/8 of the card's, and doesn't count the document against a card whose memory is known. The status bar shows GPU Memory Full while they don't fit. Ustaw Pamięć GPU w Preferencje › Wydajność na tyle, ile zmieści Twoja karta graficzna, najwyżej 3/8 jej pamięci. Automatycznie podąża za Użyciem pamięci, w granicach jednej czwartej pamięci tego komputera i 3/8 pamięci karty, i nie wlicza dokumentu przy karcie o znanej pamięci. Dopóki się nie mieszczą, pasek stanu pokazuje Pamięć GPU pełna. GPU Memory Full Pamięć GPU pełna The layers need {needed} of GPU memory and may hold {budget}, so edits that redraw the whole document do it on the CPU. Click to set GPU Memory. Warstwy potrzebują {needed} pamięci GPU i mogą zająć {budget}, więc edycje, które odświeżają cały dokument, robią to na CPU. Kliknij, aby ustawić Pamięć GPU. GPU memory budget: {budget} Budżet pamięci GPU: {budget} diff --git a/crates/ui-egui/src/i18n/pt-br.tsv b/crates/ui-egui/src/i18n/pt-br.tsv index 5f0b254f64..8d6b88fb54 100644 --- a/crates/ui-egui/src/i18n/pt-br.tsv +++ b/crates/ui-egui/src/i18n/pt-br.tsv @@ -3991,7 +3991,7 @@ layerLabel No Color Sem cor The transform couldn't be applied. Não foi possível aplicar a transformação. # Preferences › Performance › GPU Memory; the status bar and slow-redraw notice Its layers need {needed} of GPU memory; the budget leaves them {budget}. As camadas precisam de {needed} de memória da GPU; o orçamento lhes deixa {budget}. - Set GPU Memory in Preferences › Performance to what your graphics card can hold (Automatic follows Memory Usage, up to a quarter of this computer's memory). The status bar shows GPU Memory Full while they don't fit. Ajuste Memória da GPU em Preferências › Desempenho para o que sua placa de vídeo comporta (Automático segue Uso de memória, até um quarto da memória deste computador). Enquanto não couberem, a barra de status mostra Memória da GPU cheia. + Set GPU Memory in Preferences › Performance to what your graphics card can hold, at most 3/8 of its memory. Automatic follows Memory Usage, within a quarter of this computer's memory and 3/8 of the card's, and doesn't count the document against a card whose memory is known. The status bar shows GPU Memory Full while they don't fit. Ajuste Memória da GPU em Preferências › Desempenho para o que sua placa de vídeo comporta, no máximo 3/8 da memória dela. Automático segue Uso de memória, dentro de um quarto da memória deste computador e 3/8 da memória da placa, e não desconta o documento de uma placa cuja memória é conhecida. Enquanto não couberem, a barra de status mostra Memória da GPU cheia. GPU Memory Full Memória da GPU cheia The layers need {needed} of GPU memory and may hold {budget}, so edits that redraw the whole document do it on the CPU. Click to set GPU Memory. As camadas precisam de {needed} de memória da GPU e podem ocupar {budget}, então as edições que redesenham o documento inteiro fazem isso na CPU. Clique para ajustar Memória da GPU. GPU memory budget: {budget} Orçamento de memória da GPU: {budget} diff --git a/crates/ui-egui/src/i18n/ru.tsv b/crates/ui-egui/src/i18n/ru.tsv index 03247c0c49..9d733f11a7 100644 --- a/crates/ui-egui/src/i18n/ru.tsv +++ b/crates/ui-egui/src/i18n/ru.tsv @@ -3171,7 +3171,7 @@ curves Smooth Сгладить The transform couldn't be applied. Не удалось применить трансформирование. # Preferences › Performance › GPU Memory; the status bar and slow-redraw notice Its layers need {needed} of GPU memory; the budget leaves them {budget}. Слоям нужно {needed} памяти GPU; бюджет оставляет им {budget}. - Set GPU Memory in Preferences › Performance to what your graphics card can hold (Automatic follows Memory Usage, up to a quarter of this computer's memory). The status bar shows GPU Memory Full while they don't fit. Задайте «Память GPU» в разделе Настройки › Производительность по объёму, который вмещает ваша видеокарта («Автоматически» следует параметру «Память», но не больше четверти памяти этого компьютера). Пока слои не помещаются, в строке состояния показано «Память GPU заполнена». + Set GPU Memory in Preferences › Performance to what your graphics card can hold, at most 3/8 of its memory. Automatic follows Memory Usage, within a quarter of this computer's memory and 3/8 of the card's, and doesn't count the document against a card whose memory is known. The status bar shows GPU Memory Full while they don't fit. Задайте «Память GPU» в разделе Настройки › Производительность по объёму, который вмещает ваша видеокарта, не больше 3/8 её памяти. «Автоматически» следует параметру «Память», в пределах четверти памяти этого компьютера и 3/8 памяти видеокарты, и не учитывает документ на видеокарте с известным объёмом памяти. Пока слои не помещаются, в строке состояния показано «Память GPU заполнена». GPU Memory Full Память GPU заполнена The layers need {needed} of GPU memory and may hold {budget}, so edits that redraw the whole document do it on the CPU. Click to set GPU Memory. Слоям нужно {needed} памяти GPU, а занимать они могут {budget}, поэтому правки, перерисовывающие весь документ, выполняются на CPU. Нажмите, чтобы задать «Память GPU». GPU memory budget: {budget} Бюджет памяти GPU: {budget} diff --git a/crates/ui-egui/src/i18n/sr.tsv b/crates/ui-egui/src/i18n/sr.tsv index d63ea98c5c..d384c9badc 100644 --- a/crates/ui-egui/src/i18n/sr.tsv +++ b/crates/ui-egui/src/i18n/sr.tsv @@ -4274,7 +4274,7 @@ shape Appearance Izgled The transform couldn't be applied. Transformacija nije mogla da se primeni. # Preferences › Performance › GPU Memory; the status bar and slow-redraw notice Its layers need {needed} of GPU memory; the budget leaves them {budget}. Njegovim slojevima treba {needed} GPU memorije; budžet im ostavlja {budget}. - Set GPU Memory in Preferences › Performance to what your graphics card can hold (Automatic follows Memory Usage, up to a quarter of this computer's memory). The status bar shows GPU Memory Full while they don't fit. Podesite GPU memoriju u Podešavanja › Performanse na onoliko koliko vaša grafička kartica može da primi (Automatski prati Korišćenje memorije, do četvrtine memorije ovog računara). Dok ne staju, statusna traka prikazuje GPU memorija je puna. + Set GPU Memory in Preferences › Performance to what your graphics card can hold, at most 3/8 of its memory. Automatic follows Memory Usage, within a quarter of this computer's memory and 3/8 of the card's, and doesn't count the document against a card whose memory is known. The status bar shows GPU Memory Full while they don't fit. Podesite GPU memoriju u Podešavanja › Performanse na onoliko koliko vaša grafička kartica može da primi, najviše 3/8 njene memorije. Automatski prati Korišćenje memorije, u okviru četvrtine memorije ovog računara i 3/8 memorije kartice, i ne računa dokument kod kartice čija je memorija poznata. Dok ne staju, statusna traka prikazuje GPU memorija je puna. GPU Memory Full GPU memorija je puna The layers need {needed} of GPU memory and may hold {budget}, so edits that redraw the whole document do it on the CPU. Click to set GPU Memory. Slojevima treba {needed} GPU memorije, a smeju da zauzmu {budget}, pa izmene koje ponovo iscrtavaju ceo dokument to rade na CPU-u. Kliknite da podesite GPU memoriju. GPU memory budget: {budget} Budžet GPU memorije: {budget} diff --git a/crates/ui-egui/src/i18n/tr.tsv b/crates/ui-egui/src/i18n/tr.tsv index aaa50e4791..5c35aba71c 100644 --- a/crates/ui-egui/src/i18n/tr.tsv +++ b/crates/ui-egui/src/i18n/tr.tsv @@ -3931,7 +3931,7 @@ cameraData Lens Lens The transform couldn't be applied. Dönüştürme uygulanamadı. # Preferences › Performance › GPU Memory; the status bar and slow-redraw notice Its layers need {needed} of GPU memory; the budget leaves them {budget}. Katmanlarının {needed} GPU belleğine ihtiyacı var; bütçe onlara {budget} bırakıyor. - Set GPU Memory in Preferences › Performance to what your graphics card can hold (Automatic follows Memory Usage, up to a quarter of this computer's memory). The status bar shows GPU Memory Full while they don't fit. Tercihler › Performans içindeki GPU Belleği'ni ekran kartınızın kaldırabileceği miktara ayarlayın (Otomatik, Bellek kullanımını izler; en fazla bu bilgisayarın belleğinin dörtte biri). Sığmadıkları sürece durum çubuğu GPU Belleği Dolu gösterir. + Set GPU Memory in Preferences › Performance to what your graphics card can hold, at most 3/8 of its memory. Automatic follows Memory Usage, within a quarter of this computer's memory and 3/8 of the card's, and doesn't count the document against a card whose memory is known. The status bar shows GPU Memory Full while they don't fit. Tercihler › Performans içindeki GPU Belleği'ni ekran kartınızın kaldırabileceği miktara, en fazla belleğinin 3/8'ine ayarlayın. Otomatik, Bellek kullanımını izler; bu bilgisayarın belleğinin dörtte biri ve kartın belleğinin 3/8'i içinde kalır, belleği bilinen bir kartta belgeyi hesaba katmaz. Sığmadıkları sürece durum çubuğu GPU Belleği Dolu gösterir. GPU Memory Full GPU Belleği Dolu The layers need {needed} of GPU memory and may hold {budget}, so edits that redraw the whole document do it on the CPU. Click to set GPU Memory. Katmanların {needed} GPU belleğine ihtiyacı var ve en fazla {budget} kullanabilirler; bu yüzden tüm belgeyi yeniden çizen düzenlemeler bunu CPU'da yapar. GPU Belleği'ni ayarlamak için tıklayın. GPU memory budget: {budget} GPU bellek bütçesi: {budget} diff --git a/crates/ui-egui/src/i18n/uk.tsv b/crates/ui-egui/src/i18n/uk.tsv index aa0fabff4d..cb6f883eb2 100644 --- a/crates/ui-egui/src/i18n/uk.tsv +++ b/crates/ui-egui/src/i18n/uk.tsv @@ -3900,7 +3900,7 @@ curves Smooth Згладити The transform couldn't be applied. Не вдалося застосувати трансформування. # Preferences › Performance › GPU Memory; the status bar and slow-redraw notice Its layers need {needed} of GPU memory; the budget leaves them {budget}. Його шарам потрібно {needed} пам'яті GPU; бюджет залишає їм {budget}. - Set GPU Memory in Preferences › Performance to what your graphics card can hold (Automatic follows Memory Usage, up to a quarter of this computer's memory). The status bar shows GPU Memory Full while they don't fit. Задайте «Пам'ять GPU» в Налаштування › Продуктивність відповідно до того, скільки вміщує ваша відеокарта («Автоматично» стежить за використанням пам'яті, до чверті пам'яті цього комп'ютера). Поки шари не вміщуються, рядок стану показує «Пам'ять GPU заповнена». + Set GPU Memory in Preferences › Performance to what your graphics card can hold, at most 3/8 of its memory. Automatic follows Memory Usage, within a quarter of this computer's memory and 3/8 of the card's, and doesn't count the document against a card whose memory is known. The status bar shows GPU Memory Full while they don't fit. Задайте «Пам'ять GPU» в Налаштування › Продуктивність відповідно до того, скільки вміщує ваша відеокарта, не більше 3/8 її пам'яті. «Автоматично» стежить за використанням пам'яті, у межах чверті пам'яті цього комп'ютера й 3/8 пам'яті відеокарти, і не враховує документ на відеокарті з відомим обсягом пам'яті. Поки шари не вміщуються, рядок стану показує «Пам'ять GPU заповнена». GPU Memory Full Пам'ять GPU заповнена The layers need {needed} of GPU memory and may hold {budget}, so edits that redraw the whole document do it on the CPU. Click to set GPU Memory. Шарам потрібно {needed} пам'яті GPU, а займати вони можуть {budget}, тому зміни, що перемальовують увесь документ, виконуються на CPU. Натисніть, щоб задати «Пам'ять GPU». GPU memory budget: {budget} Бюджет пам'яті GPU: {budget} diff --git a/crates/ui-egui/src/i18n/vi.tsv b/crates/ui-egui/src/i18n/vi.tsv index 49ad7f59fc..e2e55ab1ba 100644 --- a/crates/ui-egui/src/i18n/vi.tsv +++ b/crates/ui-egui/src/i18n/vi.tsv @@ -3687,7 +3687,7 @@ curves Smooth Làm mượt The transform couldn't be applied. Không thể áp dụng phép biến đổi. # Preferences › Performance › GPU Memory; the status bar and slow-redraw notice Its layers need {needed} of GPU memory; the budget leaves them {budget}. Các lớp cần {needed} bộ nhớ GPU; ngân sách chỉ dành cho chúng {budget}. - Set GPU Memory in Preferences › Performance to what your graphics card can hold (Automatic follows Memory Usage, up to a quarter of this computer's memory). The status bar shows GPU Memory Full while they don't fit. Đặt Bộ nhớ GPU trong Tùy chọn › Hiệu năng bằng mức card đồ họa của bạn chứa được (Tự động theo mức sử dụng bộ nhớ, tối đa một phần tư bộ nhớ máy này). Khi chúng chưa vừa, thanh trạng thái hiện Bộ nhớ GPU đầy. + Set GPU Memory in Preferences › Performance to what your graphics card can hold, at most 3/8 of its memory. Automatic follows Memory Usage, within a quarter of this computer's memory and 3/8 of the card's, and doesn't count the document against a card whose memory is known. The status bar shows GPU Memory Full while they don't fit. Đặt Bộ nhớ GPU trong Tùy chọn › Hiệu năng bằng mức card đồ họa của bạn chứa được, tối đa 3/8 bộ nhớ của card. Tự động theo mức sử dụng bộ nhớ, trong giới hạn một phần tư bộ nhớ máy này và 3/8 bộ nhớ card, và không tính tài liệu vào card đã biết dung lượng bộ nhớ. Khi chúng chưa vừa, thanh trạng thái hiện Bộ nhớ GPU đầy. GPU Memory Full Bộ nhớ GPU đầy The layers need {needed} of GPU memory and may hold {budget}, so edits that redraw the whole document do it on the CPU. Click to set GPU Memory. Các lớp cần {needed} bộ nhớ GPU và chỉ được dùng {budget}, nên những chỉnh sửa vẽ lại toàn bộ tài liệu sẽ làm trên CPU. Nhấp để đặt Bộ nhớ GPU. GPU memory budget: {budget} Ngân sách bộ nhớ GPU: {budget} diff --git a/crates/ui-egui/src/i18n/zh-hans.tsv b/crates/ui-egui/src/i18n/zh-hans.tsv index 0186d4cf7e..eebeabaa6c 100644 --- a/crates/ui-egui/src/i18n/zh-hans.tsv +++ b/crates/ui-egui/src/i18n/zh-hans.tsv @@ -2817,7 +2817,7 @@ curves Smooth 平滑 The transform couldn't be applied. 无法应用变换。 # Preferences › Performance › GPU Memory; the status bar and slow-redraw notice Its layers need {needed} of GPU memory; the budget leaves them {budget}. 其图层需要 {needed} GPU 内存;预算只留给它们 {budget}。 - Set GPU Memory in Preferences › Performance to what your graphics card can hold (Automatic follows Memory Usage, up to a quarter of this computer's memory). The status bar shows GPU Memory Full while they don't fit. 在 首选项 › 性能 中将 GPU 内存设置为显卡能容纳的大小(自动跟随内存使用量,最多为本机内存的四分之一)。在图层放不下期间,状态栏显示“GPU 内存已满”。 + Set GPU Memory in Preferences › Performance to what your graphics card can hold, at most 3/8 of its memory. Automatic follows Memory Usage, within a quarter of this computer's memory and 3/8 of the card's, and doesn't count the document against a card whose memory is known. The status bar shows GPU Memory Full while they don't fit. 在 首选项 › 性能 中将 GPU 内存设置为显卡能容纳的大小,最多为其显存的 3/8。自动跟随内存使用量,不超过本机内存的四分之一和显卡显存的 3/8,且在显存已知的显卡上不把文档计入。在图层放不下期间,状态栏显示“GPU 内存已满”。 GPU Memory Full GPU 内存已满 The layers need {needed} of GPU memory and may hold {budget}, so edits that redraw the whole document do it on the CPU. Click to set GPU Memory. 图层需要 {needed} GPU 内存,但只能占用 {budget},因此重绘整个文档的编辑会在 CPU 上进行。点击以设置 GPU 内存。 GPU memory budget: {budget} GPU 内存预算:{budget} diff --git a/crates/ui-egui/src/i18n/zh-hant.tsv b/crates/ui-egui/src/i18n/zh-hant.tsv index f87c71ccd9..b35556b6fe 100644 --- a/crates/ui-egui/src/i18n/zh-hant.tsv +++ b/crates/ui-egui/src/i18n/zh-hant.tsv @@ -2802,7 +2802,7 @@ curves Smooth 平滑 The transform couldn't be applied. 無法套用變形。 # Preferences › Performance › GPU Memory; the status bar and slow-redraw notice Its layers need {needed} of GPU memory; the budget leaves them {budget}. 其圖層需要 {needed} GPU 記憶體;預算只留給它們 {budget}。 - Set GPU Memory in Preferences › Performance to what your graphics card can hold (Automatic follows Memory Usage, up to a quarter of this computer's memory). The status bar shows GPU Memory Full while they don't fit. 在 偏好設定 › 效能 中將 GPU 記憶體設為顯示卡能容納的大小(自動會跟隨記憶體使用量,最多為本機記憶體的四分之一)。在圖層放不下期間,狀態列會顯示「GPU 記憶體已滿」。 + Set GPU Memory in Preferences › Performance to what your graphics card can hold, at most 3/8 of its memory. Automatic follows Memory Usage, within a quarter of this computer's memory and 3/8 of the card's, and doesn't count the document against a card whose memory is known. The status bar shows GPU Memory Full while they don't fit. 在 偏好設定 › 效能 中將 GPU 記憶體設為顯示卡能容納的大小,最多為其記憶體的 3/8。自動會跟隨記憶體使用量,不超過本機記憶體的四分之一和顯示卡記憶體的 3/8,且在記憶體已知的顯示卡上不把文件計入。在圖層放不下期間,狀態列會顯示「GPU 記憶體已滿」。 GPU Memory Full GPU 記憶體已滿 The layers need {needed} of GPU memory and may hold {budget}, so edits that redraw the whole document do it on the CPU. Click to set GPU Memory. 圖層需要 {needed} GPU 記憶體,但只能佔用 {budget},因此重繪整份文件的編輯會在 CPU 上進行。按一下以設定 GPU 記憶體。 GPU memory budget: {budget} GPU 記憶體預算:{budget} diff --git a/crates/ui-egui/src/notices.rs b/crates/ui-egui/src/notices.rs index d1ed6d37cc..cd974fbbe7 100644 --- a/crates/ui-egui/src/notices.rs +++ b/crates/ui-egui/src/notices.rs @@ -112,7 +112,7 @@ pub fn slow_cpu_refresh(app: &mut PhotocraftApp, doc: u64, ms: f64, reason: Opti )); } lines.push( - tl!("Set GPU Memory in Preferences › Performance to what your graphics card can hold (Automatic follows Memory Usage, up to a quarter of this computer's memory). The status bar shows GPU Memory Full while they don't fit.") + tl!("Set GPU Memory in Preferences › Performance to what your graphics card can hold, at most 3/8 of its memory. Automatic follows Memory Usage, within a quarter of this computer's memory and 3/8 of the card's, and doesn't count the document against a card whose memory is known. The status bar shows GPU Memory Full while they don't fit.") .into(), ); } else if let Some(r) = reason { From 58f855a40f67a9b6a7e9168ea03c261226845705 Mon Sep 17 00:00:00 2001 From: Zuujis <160198409+zuujiscom@users.noreply.github.com> Date: Thu, 15 Oct 2026 23:47:17 -0500 Subject: [PATCH 45/47] Save As on Linux: the file type picked sets the extension (#6121, #4328, #4394) (#6151) * Save As on Linux: the file type picked sets the extension (#6121, #4328, #4394) The GTK file chooser behind the desktop portal keeps the typed name when another file type is picked, and rfd 0.17.2 reads only the file name from the portal's answer, so Untitled-1.psd with PNG picked was written as a PSD. On Linux, Save As now asks the portal's FileChooser.SaveFile itself (over the zbus already used for the portal), reads current_filter, and gives the name that type's extension: an extension PhotoCraft saves is replaced, none or another is appended. If that names an existing file, the dialog opens again under the new name so the portal asks before replacing it. rfd remains the fallback without a portal, and this can go once an rfd release reads current_filter (PolyMeilex/rfd#341). Co-Authored-By: Claude Opus 5.5 * Save As on Linux: keep typed extensions, open on the stem, follow the request handle Review follow-ups, checked on real GNOME 46 and KDE Plasma 6.7.5 sessions (Wayland and X11): - An extension the user typed is kept, as in PolyMeilex/rfd#341: only a name with none, or still with the extension the dialog opened with, takes the picked type's. GTK reports the type even when it is untouched, so art.png with the PSD type left alone was saved as art.psd. - The dialog opens on the stem (test), as on Windows (#4407): with test.psd in the name and PNG picked, GTK asked to replace test.psd, which the save leaves alone. A name that then exists is asked about again under that name; the rule compares against the suggested name, so a test.psd GTK completes back still becomes test.png. - Response 2 (GNOME's dialog closed with Esc) is a cancel: it fell back to rfd, which opened a second Save As. - The answer is awaited on the handle SaveFile returns, which a portal older than 0.9 picks itself (the Request documentation). Co-Authored-By: Claude Opus 5.5 * style(automation): format smart filter refresh closure --------- Co-authored-by: Claude Opus 5.5 Co-authored-by: skylar taylor-barrick --- apps/photocraft/src/main.rs | 3 + apps/photocraft/src/portal_save.rs | 183 ++++++++++++++++++ apps/photocraft/src/portal_save/tests.rs | 232 +++++++++++++++++++++++ apps/photocraft/src/services.rs | 131 +++++++++++-- 4 files changed, 535 insertions(+), 14 deletions(-) create mode 100644 apps/photocraft/src/portal_save.rs create mode 100644 apps/photocraft/src/portal_save/tests.rs diff --git a/apps/photocraft/src/main.rs b/apps/photocraft/src/main.rs index 0b82237a39..837332962f 100644 --- a/apps/photocraft/src/main.rs +++ b/apps/photocraft/src/main.rs @@ -50,6 +50,9 @@ mod linux_libs; mod logging; mod monitor_profile; mod photoshop_settings; +// Save As through the desktop portal, which reports the file type picked (#6121). +#[cfg(target_os = "linux")] +mod portal_save; mod presentation; mod screen_color; mod services; diff --git a/apps/photocraft/src/portal_save.rs b/apps/photocraft/src/portal_save.rs new file mode 100644 index 0000000000..42588e2bc4 --- /dev/null +++ b/apps/photocraft/src/portal_save.rs @@ -0,0 +1,183 @@ +//! File › Save As through the XDG desktop portal on Linux (#6121, #4328, #4394). +//! +//! The GTK file chooser behind the portal keeps the typed name when the user picks another file +//! type, and rfd 0.17 reads only the file name from the portal's answer, so `image.psd` with PNG +//! picked came back as `image.psd` and was written as a PSD. The answer does say which type was +//! picked (`current_filter`), so Save As asks the portal itself and gives a name the dialog left +//! alone that type's extension ([`crate::services::with_chosen_type`]). rfd stays the fallback +//! (zenity, or no portal), and this can go once an rfd release reads the picked type +//! (PolyMeilex/rfd#341). +//! +//! The dialog is attached to the window on X11. On Wayland it has no parent: naming the window to +//! the portal needs an xdg-foreign handle, which takes `unsafe` code the workspace forbids. + +use std::collections::HashMap; +use std::path::{Path, PathBuf}; + +use zbus::zvariant::{OwnedObjectPath, OwnedValue, Value}; + +/// One file type in the dialog: its label and extensions (`("PNG", ["png"])`). +pub type Filter = (String, Vec); + +/// A Save dialog to show. +#[derive(Clone, Debug, Default, PartialEq, Eq)] +pub struct SaveRequest { + /// The portal's parent window identifier (`x11:`), or empty. + pub parent: String, + /// The name the dialog shows: the stem of the suggested file (`test`), see + /// `services::dialog_file_name`. + pub name: Option, + /// The suggested file's name (`test.psd`): a name that comes back with its extension still + /// takes the extension of the type picked ([`crate::services::with_chosen_type`]). + pub suggested: Option, + pub folder: Option, + pub filters: Vec, + /// The filter the dialog starts on. + pub current: Option, +} + +/// What the user chose: the path, as typed, and the label of the file type picked. +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct Saved { + pub path: PathBuf, + pub filter: Option, +} + +/// The portal's parent window identifier for `window`: `x11:` on X11, empty elsewhere. +pub fn parent_id(window: Option) -> String { + use eframe::wgpu::rwh::RawWindowHandle; + match window { + Some(RawWindowHandle::Xlib(h)) => format!("x11:{:x}", h.window), + Some(RawWindowHandle::Xcb(h)) => format!("x11:{:x}", h.window.get()), + _ => String::new(), + } +} + +/// Shows the portal's Save dialog and waits for it. `Ok(None)` is Cancel; `Err` means the portal +/// couldn't show it (no portal, no FileChooser), so the caller falls back to rfd. +pub fn save_file(conn: &zbus::blocking::Connection, req: &SaveRequest) -> Result, String> { + use futures_lite::StreamExt; + use zbus::blocking::Proxy; + let unique = conn.unique_name().ok_or("Portal connection has no unique name")?.as_str().trim_start_matches(':').replace('.', "_"); + static NEXT: std::sync::atomic::AtomicU32 = std::sync::atomic::AtomicU32::new(0); + let token = format!("photocraft_save_{}_{}", std::process::id(), NEXT.fetch_add(1, std::sync::atomic::Ordering::Relaxed)); + let path = format!("/org/freedesktop/portal/desktop/request/{unique}/{token}"); + // Listen before asking, so a quick answer isn't missed. + let request = Proxy::new(conn, "org.freedesktop.portal.Desktop", path.as_str(), "org.freedesktop.portal.Request").map_err(|e| e.to_string())?; + let mut replies = futures_lite::future::block_on(request.inner().receive_signal("Response")).map_err(|e| e.to_string())?; + let chooser = Proxy::new(conn, "org.freedesktop.portal.Desktop", "/org/freedesktop/portal/desktop", "org.freedesktop.portal.FileChooser") + .map_err(|e| e.to_string())?; + let handle: OwnedObjectPath = chooser.call("SaveFile", &(req.parent.as_str(), "Save As", options(req, &token))).map_err(|e| e.to_string())?; + // A portal older than 0.9 ignores `handle_token` and answers on a request path of its own, + // which it returns: listen there instead, as the Request documentation asks. + let reply = if handle.as_str() == path { + futures_lite::future::block_on(replies.next()) + } else { + let request = Proxy::new(conn, "org.freedesktop.portal.Desktop", handle.as_str(), "org.freedesktop.portal.Request").map_err(|e| e.to_string())?; + let mut replies = futures_lite::future::block_on(request.inner().receive_signal("Response")).map_err(|e| e.to_string())?; + futures_lite::future::block_on(replies.next()) + } + .ok_or("The save dialog closed without an answer")?; + let (code, results): (u32, HashMap) = reply.body().deserialize().map_err(|e| e.to_string())?; + answer(code, &results) +} + +/// The `SaveFile` options for `req`. +fn options<'a>(req: &'a SaveRequest, token: &'a str) -> HashMap<&'static str, Value<'a>> { + let mut o: HashMap<&'static str, Value<'a>> = HashMap::from([("handle_token", Value::from(token)), ("modal", Value::from(true))]); + if let Some(name) = &req.name { + o.insert("current_name", Value::from(name.as_str())); + } + if let Some(folder) = &req.folder { + // A byte string with its terminating NUL, as the portal specifies. + use std::os::unix::ffi::OsStrExt; + let mut bytes = folder.as_os_str().as_bytes().to_vec(); + bytes.push(0); + o.insert("current_folder", Value::from(bytes)); + } + let wire = |(label, exts): &Filter| (label.clone(), exts.iter().map(|e| (0u32, format!("*.{e}"))).collect::>()); + if !req.filters.is_empty() { + o.insert("filters", Value::from(req.filters.iter().map(wire).collect::>())); + } + if let Some(f) = req.current.and_then(|i| req.filters.get(i)) { + o.insert("current_filter", Value::from(wire(f))); + } + o +} + +/// Reads the portal's `Response`: code 0 with the first `uris` entry. 1 is Cancel, and 2 a dialog +/// closed some other way, such as with Esc in GNOME's: no file either, and not a reason to show +/// rfd's dialog instead (it did, so Esc opened a second Save As). +fn answer(code: u32, results: &HashMap) -> Result, String> { + match code { + 0 => {} + 1 | 2 => return Ok(None), + _ => return Err(format!("the save dialog failed (portal response {code})")), + } + let uris: Vec = results + .get("uris") + .ok_or("no file in the portal's answer")? + .try_clone() + .map_err(|e| e.to_string())? + .try_into() + .map_err(|e: zbus::zvariant::Error| e.to_string())?; + let uri = uris.first().ok_or("no file in the portal's answer")?; + let path = file_uri_path(uri).ok_or_else(|| format!("not a local file: {uri}"))?; + let filter = results.get("current_filter").and_then(|v| { + let (label, _globs): (String, Vec<(u32, String)>) = v.try_clone().ok()?.try_into().ok()?; + Some(label) + }); + Ok(Some(Saved { path, filter })) +} + +/// `file:///home/me/A%20b.psd` → `/home/me/A b.psd`; `None` for other schemes. +fn file_uri_path(uri: &str) -> Option { + let rest = uri.strip_prefix("file://")?; + // Only local files: `file:///…` or `file://localhost/…`. + let rest = rest.strip_prefix("localhost").unwrap_or(rest); + if !rest.starts_with('/') { + return None; + } + let bytes = rest.as_bytes(); + let mut out = Vec::with_capacity(bytes.len()); + let mut i = 0; + while i < bytes.len() { + if bytes[i] == b'%' + && let Some(b) = bytes.get(i + 1..i + 3).and_then(|h| std::str::from_utf8(h).ok()).and_then(|h| u8::from_str_radix(h, 16).ok()) + { + out.push(b); + i += 3; + } else { + out.push(bytes[i]); + i += 1; + } + } + use std::os::unix::ffi::OsStringExt; + Some(PathBuf::from(std::ffi::OsString::from_vec(out))) +} + +/// Save As on Linux: the portal's dialog, with the name given the extension of the file type +/// picked. When that changes the name to one that already exists, the dialog opens again under +/// the new name, so the portal asks before replacing it (it asked only about the typed name). +pub fn save_as(conn: &zbus::blocking::Connection, mut req: SaveRequest) -> Result, String> { + loop { + let Some(saved) = save_file(conn, &req)? else { return Ok(None) }; + let picked = saved.filter.as_deref().and_then(|label| req.filters.iter().position(|(l, _)| l == label)); + // With one type offered there is nothing to pick, and a typed extension is the user's. + let exts: &[String] = match picked { + Some(i) if req.filters.len() > 1 => &req.filters[i].1, + _ => &[], + }; + let path = crate::services::with_chosen_type(&saved.path, req.suggested.as_deref(), exts); + if path == saved.path || !path.exists() { + return Ok(Some(path)); + } + req.name = path.file_name().map(|n| n.to_string_lossy().into_owned()); + req.suggested = req.name.clone(); + req.folder = path.parent().map(Path::to_path_buf); + req.current = picked; + } +} + +#[cfg(test)] +mod tests; diff --git a/apps/photocraft/src/portal_save/tests.rs b/apps/photocraft/src/portal_save/tests.rs new file mode 100644 index 0000000000..ed44a10d9c --- /dev/null +++ b/apps/photocraft/src/portal_save/tests.rs @@ -0,0 +1,232 @@ +use super::*; +use std::sync::{Arc, Mutex}; + +#[test] +fn file_uris_become_local_paths() { + assert_eq!(file_uri_path("file:///home/me/Untitled-1.psd"), Some(PathBuf::from("/home/me/Untitled-1.psd"))); + assert_eq!(file_uri_path("file:///home/me/A%20b%C3%A9.png"), Some(PathBuf::from("/home/me/A bé.png"))); + assert_eq!(file_uri_path("file://localhost/tmp/x.png"), Some(PathBuf::from("/tmp/x.png"))); + assert_eq!(file_uri_path("sftp://host/x.png"), None); + assert_eq!(file_uri_path("file://host/x.png"), None); +} + +#[test] +fn x11_windows_are_named_to_the_portal() { + use eframe::wgpu::rwh::{RawWindowHandle, XlibWindowHandle}; + assert_eq!(parent_id(Some(RawWindowHandle::Xlib(XlibWindowHandle::new(0x3a0000f)))), "x11:3a0000f"); + assert_eq!(parent_id(None), ""); +} + +/// What the mock portal answers to one `SaveFile` call: the response code, and the file and +/// filter label the user "picked". +type Script = Vec<(u32, Option<(&'static str, &'static str)>)>; + +/// The options of each `SaveFile` call the mock portal received: current_name and the label of +/// current_filter. +type Seen = Arc, Option)>>>; + +/// A private session bus (`dbus-daemon`) with a mock `org.freedesktop.portal.FileChooser` that +/// answers `SaveFile` from `script`. `None` when there is no `dbus-daemon` to run. +fn mock_portal(script: Script) -> Option<(zbus::blocking::Connection, Seen, std::process::Child)> { + mock_portal_with(script, false) +} + +/// [`mock_portal`]; with `own_paths` it is a portal older than 0.9, which ignores `handle_token`, +/// answers on a request path of its own and returns that from `SaveFile`, as a real dialog does +/// once the user is done with it (here after 300 ms). +fn mock_portal_with(script: Script, own_paths: bool) -> Option<(zbus::blocking::Connection, Seen, std::process::Child)> { + use std::io::BufRead; + let mut daemon = + std::process::Command::new("dbus-daemon").args(["--session", "--nofork", "--print-address=1"]).stdout(std::process::Stdio::piped()).spawn().ok()?; + let mut address = String::new(); + std::io::BufReader::new(daemon.stdout.take()?).read_line(&mut address).ok()?; + let address = address.trim().to_string(); + let server = zbus::blocking::connection::Builder::address(address.as_str()).unwrap().name("org.freedesktop.portal.Desktop").unwrap().build().unwrap(); + let seen: Seen = Arc::default(); + let log = seen.clone(); + std::thread::spawn(move || { + let mut script = script.into_iter(); + for msg in zbus::blocking::MessageIterator::from(&server) { + let Ok(msg) = msg else { break }; + let header = msg.header(); + if header.message_type() != zbus::message::Type::MethodCall || header.member().map(|m| m.as_str()) != Some("SaveFile") { + continue; + } + let (_parent, _title, options): (String, String, HashMap) = msg.body().deserialize().unwrap(); + let text = |key: &str| options.get(key).and_then(|v| String::try_from(v.try_clone().ok()?).ok()); + let filter = options.get("current_filter").and_then(|v| { + let (label, _): (String, Vec<(u32, String)>) = v.try_clone().ok()?.try_into().ok()?; + Some(label) + }); + log.lock().unwrap().push((text("current_name"), filter)); + let sender = header.sender().unwrap().as_str().trim_start_matches(':').replace('.', "_"); + let token = if own_paths { format!("old{}", log.lock().unwrap().len()) } else { text("handle_token").unwrap() }; + let path = format!("/org/freedesktop/portal/desktop/request/{sender}/{token}"); + server.reply(&header, &OwnedObjectPath::try_from(path.as_str()).unwrap()).unwrap(); + if own_paths { + std::thread::sleep(std::time::Duration::from_millis(300)); + } + let (code, pick) = script.next().unwrap_or((2, None)); + let mut results: HashMap<&str, Value<'_>> = HashMap::new(); + if let Some((uri, label)) = pick { + results.insert("uris", Value::from(vec![uri.to_string()])); + results.insert("current_filter", Value::from((label.to_string(), vec![(0u32, "*.x".to_string())]))); + } + server + .emit_signal( + header.sender().cloned().map(zbus::names::BusName::from), + path.as_str(), + "org.freedesktop.portal.Request", + "Response", + &(code, results), + ) + .unwrap(); + } + }); + let client = zbus::blocking::connection::Builder::address(address.as_str()).unwrap().build().unwrap(); + Some((client, seen, daemon)) +} + +fn request(name: &str) -> SaveRequest { + let filters = [("PSD Document", &["psd", "psb"][..]), ("PNG", &["png"]), ("JPEG", &["jpg"])]; + SaveRequest { + parent: String::new(), + name: Some(name.into()), + suggested: Some(name.into()), + folder: Some(PathBuf::from("/tmp")), + filters: filters.iter().map(|(l, e)| (l.to_string(), e.iter().map(|s| s.to_string()).collect())).collect(), + current: Some(0), + } +} + +/// #6121 end to end through a real D-Bus: the name comes back as typed with the type picked, +/// and Save As writes it with that type's extension. +#[test] +fn save_as_follows_the_file_type_picked_in_the_portal_dialog() { + let dir = std::env::temp_dir().join(format!("photocraft-portal-{}", std::process::id())); + std::fs::create_dir_all(&dir).unwrap(); + let typed = format!("file://{}/Untitled-1.psd", dir.display()); + let typed: &'static str = Box::leak(typed.into_boxed_str()); + let Some((conn, seen, mut daemon)) = mock_portal(vec![(0, Some((typed, "PNG"))), (1, None), (0, Some((typed, "PSD Document")))]) else { + eprintln!("no dbus-daemon: skipped"); + return; + }; + // PNG picked with `Untitled-1.psd` still in the name: written as Untitled-1.png. + assert_eq!(save_as(&conn, request("Untitled-1.psd")).unwrap(), Some(dir.join("Untitled-1.png"))); + assert_eq!(seen.lock().unwrap()[0], (Some("Untitled-1.psd".into()), Some("PSD Document".into())), "the request carried the name and type"); + // Cancel. + assert_eq!(save_as(&conn, request("Untitled-1.psd")).unwrap(), None); + // The type the name already has: unchanged. + assert_eq!(save_as(&conn, request("Untitled-1.psd")).unwrap(), Some(dir.join("Untitled-1.psd"))); + daemon.kill().ok(); + std::fs::remove_dir_all(&dir).ok(); +} + +/// The dialog opens on the bare name (`test`, see `services::dialog_file_name`), so GTK doesn't +/// ask to replace test.psd when PNG is picked: the type picked gives the extension, and a name +/// that then exists is asked about under that name. +#[test] +fn a_bare_name_takes_the_picked_type_and_an_existing_target_is_asked_about() { + let dir = std::env::temp_dir().join(format!("photocraft-portal-bare-{}", std::process::id())); + std::fs::create_dir_all(&dir).unwrap(); + std::fs::write(dir.join("test.psd"), b"open document").unwrap(); + let uri = |name: &str| -> &'static str { Box::leak(format!("file://{}/{name}", dir.display()).into_boxed_str()) }; + let Some((conn, seen, mut daemon)) = mock_portal(vec![ + (0, Some((uri("test"), "PNG"))), + (0, Some((uri("test.psd"), "PNG"))), + (0, Some((uri("test"), "PSD Document"))), + (0, Some((uri("test.psd"), "PSD Document"))), + ]) else { + eprintln!("no dbus-daemon: skipped"); + return; + }; + // As File › Save As opens it: `test` shown, `test.psd` suggested. + let opened = || SaveRequest { suggested: Some("test.psd".into()), ..request("test") }; + // PNG picked: test.png, and nothing was asked about test.psd. + assert_eq!(save_as(&conn, opened()).unwrap(), Some(dir.join("test.png"))); + // The suggested extension back in the name (typed again, or GTK's completion) with PNG + // picked: test.png too, not a PSD written over test.psd. + assert_eq!(save_as(&conn, opened()).unwrap(), Some(dir.join("test.png"))); + // The type untouched: test.psd, which exists, so the dialog asks again as test.psd (the + // portal's replace prompt is then about the file that is written). + assert_eq!(save_as(&conn, opened()).unwrap(), Some(dir.join("test.psd"))); + let seen = seen.lock().unwrap().clone(); + let names: Vec<_> = seen.iter().map(|(name, _)| name.as_deref()).collect(); + assert_eq!(names, [Some("test"), Some("test"), Some("test"), Some("test.psd")], "{seen:?}"); + assert_eq!(seen[3].1.as_deref(), Some("PSD Document")); + daemon.kill().ok(); + std::fs::remove_dir_all(&dir).ok(); +} + +/// GNOME's dialog closed with Esc answers 2 ("ended some other way"): no file, like Cancel, and +/// no fallback to rfd, which opened a second Save As. +#[test] +fn a_dialog_closed_some_other_way_is_a_cancel() { + let Some((conn, _seen, mut daemon)) = mock_portal(vec![(2, None)]) else { + eprintln!("no dbus-daemon: skipped"); + return; + }; + assert_eq!(save_as(&conn, request("test")), Ok(None)); + daemon.kill().ok(); +} + +/// An extension the user typed is theirs: `art.png` with the PSD type left as the dialog opened +/// saves a PNG, as it did before the type was read. +#[test] +fn a_typed_extension_wins_over_an_untouched_file_type() { + let typed: &'static str = "file:///tmp/photocraft-none/art.png"; + let Some((conn, _seen, mut daemon)) = mock_portal(vec![(0, Some((typed, "PSD Document")))]) else { + eprintln!("no dbus-daemon: skipped"); + return; + }; + assert_eq!(save_as(&conn, request("Untitled-1.psd")).unwrap(), Some(PathBuf::from("/tmp/photocraft-none/art.png"))); + daemon.kill().ok(); +} + +/// A portal older than 0.9 answers on a request path of its own: the wait follows the handle +/// `SaveFile` returns, as the Request documentation asks, instead of the predicted path. +#[test] +fn the_answer_is_awaited_on_the_handle_the_portal_returns() { + let typed: &'static str = "file:///tmp/photocraft-none/Untitled-1.psd"; + let Some((conn, _seen, mut daemon)) = mock_portal_with(vec![(0, Some((typed, "PNG")))], true) else { + eprintln!("no dbus-daemon: skipped"); + return; + }; + assert_eq!(save_as(&conn, request("Untitled-1.psd")).unwrap(), Some(PathBuf::from("/tmp/photocraft-none/Untitled-1.png"))); + daemon.kill().ok(); +} + +/// When the extension the type gives names a file that exists, the dialog opens again under that +/// name (so the portal asks before replacing it) instead of overwriting it unasked. +#[test] +fn a_renamed_file_that_exists_is_confirmed_in_the_dialog() { + let dir = std::env::temp_dir().join(format!("photocraft-portal-exists-{}", std::process::id())); + std::fs::create_dir_all(&dir).unwrap(); + std::fs::write(dir.join("art.png"), b"old").unwrap(); + let typed: &'static str = Box::leak(format!("file://{}/art.psd", dir.display()).into_boxed_str()); + let confirmed: &'static str = Box::leak(format!("file://{}/art.png", dir.display()).into_boxed_str()); + let Some((conn, seen, mut daemon)) = mock_portal(vec![(0, Some((typed, "PNG"))), (0, Some((confirmed, "PNG")))]) else { + eprintln!("no dbus-daemon: skipped"); + return; + }; + assert_eq!(save_as(&conn, request("art.psd")).unwrap(), Some(dir.join("art.png"))); + let seen = seen.lock().unwrap().clone(); + assert_eq!(seen.len(), 2, "asked again: {seen:?}"); + assert_eq!(seen[1], (Some("art.png".into()), Some("PNG".into())), "under the new name, on the picked type"); + assert_eq!(std::fs::read(dir.join("art.png")).unwrap(), b"old", "nothing written by the dialog"); + daemon.kill().ok(); + std::fs::remove_dir_all(&dir).ok(); +} + +/// With only one type offered (the Swatches panel's .aco) a typed extension is kept. +#[test] +fn a_single_file_type_keeps_the_typed_extension() { + let typed: &'static str = "file:///tmp/photocraft-none/Swatches.ase"; + let Some((conn, _seen, mut daemon)) = mock_portal(vec![(0, Some((typed, "Color Swatches")))]) else { + eprintln!("no dbus-daemon: skipped"); + return; + }; + let req = SaveRequest { filters: vec![("Color Swatches".into(), vec!["aco".into()])], ..request("Swatches.aco") }; + assert_eq!(save_as(&conn, req).unwrap(), Some(PathBuf::from("/tmp/photocraft-none/Swatches.ase"))); + daemon.kill().ok(); +} diff --git a/apps/photocraft/src/services.rs b/apps/photocraft/src/services.rs index 9de1c475ea..6c34fda33a 100644 --- a/apps/photocraft/src/services.rs +++ b/apps/photocraft/src/services.rs @@ -95,18 +95,51 @@ fn save_filters(suggested: &str) -> Vec<(String, Vec)> { v } -/// The name the save dialog starts with. On Windows a visible extension stays put when the user -/// picks another type (with File name extensions shown, `image.psd` saved as PhotoCraft stayed -/// `image.psd`, #4394), while a bare name gets the chosen type's extension, so there the dialog -/// gets the stem and the suggested type (listed first by [`save_filters`]) supplies `.psd`. A -/// stem that still has a dot (`scan.jpg.psd`: Windows could keep `scan.jpg`) or an upper-case +/// `path` with the extension of the file type picked in the save dialog (`chosen`), where the +/// dialog left the name alone (#6121): the format follows the extension, and the GTK file chooser +/// keeps the typed name when another type is picked. Only a name that still has the extension the +/// dialog opened with (`opened_with`, `Untitled-1.psd` with PNG picked) or none gets the type's +/// extension, as PolyMeilex/rfd#341 does. An extension the user typed that PhotoCraft saves +/// (`art.png` with the PSD type untouched, the way to save a PNG before this) is theirs and stays, +/// and so does one of the type's own (`.psb` for PSD). One PhotoCraft doesn't save (`scan.2024`) +/// is part of the name, and the type's extension is added after it. +#[cfg(any(target_os = "linux", test))] +pub(crate) fn with_chosen_type(path: &Path, opened_with: Option<&str>, chosen: &[String]) -> PathBuf { + let Some(first) = chosen.first() else { return path.to_path_buf() }; + let ext = path.extension().and_then(|e| e.to_str()).map(str::to_ascii_lowercase); + let opened_ext = opened_with.and_then(|n| Path::new(n).extension()).and_then(|e| e.to_str()).map(str::to_ascii_lowercase); + match ext { + Some(ext) if chosen.contains(&ext) => path.to_path_buf(), + Some(ext) if opened_ext.as_ref() == Some(&ext) => path.with_extension(first), + Some(ext) if SAVE_FILTERS.iter().chain(OTHER_SAVE_FILTERS).any(|(_, exts)| exts.contains(&ext.as_str())) => path.to_path_buf(), + _ => { + let mut name = path.as_os_str().to_owned(); + name.push("."); + name.push(first); + PathBuf::from(name) + } + } +} + +/// The name the save dialog starts with. Where the type picked gives a bare name its extension +/// (`adds_extension`), the dialog gets the stem and the suggested type (listed first by +/// [`save_filters`]) supplies `.psd`: +/// - On Windows a visible extension stays put when the user picks another type (with File name +/// extensions shown, `image.psd` saved as PhotoCraft stayed `image.psd`, #4394). +/// - On Linux the portal's dialog keeps the name as typed, and before returning asks whether to +/// replace a file of that name, so `test.psd` with PNG picked asked to replace test.psd, which +/// the save leaves alone (#6121). From the stem [`with_chosen_type`] gives the picked type's +/// extension, and a name that then exists is asked about under that name +/// (`portal_save::save_as`). +/// +/// A stem that still has a dot (`scan.jpg.psd`: Windows could keep `scan.jpg`) or an upper-case /// extension (the user's spelling) keeps the whole name. Elsewhere the whole name is kept: the -/// macOS panel has no type list, and on Linux rfd doesn't report which type was picked. -fn dialog_file_name(suggested: &str, windows: bool) -> Option { +/// macOS panel has no type list, and rfd's Linux dialogs don't say which type was picked. +fn dialog_file_name(suggested: &str, adds_extension: bool) -> Option { let path = Path::new(suggested); let name = path.file_name()?.to_string_lossy().into_owned(); let (Some(stem), Some(ext)) = (path.file_stem(), path.extension().and_then(|e| e.to_str())) else { return Some(name) }; - let bare = windows && Path::new(stem).extension().is_none() && !ext.is_empty() && ext == ext.to_ascii_lowercase(); + let bare = adds_extension && Path::new(stem).extension().is_none() && !ext.is_empty() && ext == ext.to_ascii_lowercase(); Some(if bare { stem.to_string_lossy().into_owned() } else { name }) } @@ -148,17 +181,52 @@ fn show_file_dialog(request: FileDialogRequest, parent: Option<&eframe::Frame>, Box::pin(async move { picked.await.map(|folder| FileDialogAnswer::FolderTo(path_of(&folder))) }) } FileDialogRequest::Save { suggested } => { - for (name, exts) in save_filters(&suggested) { - dialog = dialog.add_filter(name, &exts); + let filters = save_filters(&suggested); + let name = dialog_file_name(&suggested, cfg!(windows)); + let dir = Path::new(&suggested).parent().filter(|p| !p.as_os_str().is_empty()).map(Path::to_path_buf); + for (name, exts) in &filters { + dialog = dialog.add_filter(name, exts); } - if let Some(name) = dialog_file_name(&suggested, cfg!(windows)) { + if let Some(name) = &name { dialog = dialog.set_file_name(name); } - if let Some(dir) = Path::new(&suggested).parent().filter(|p| !p.as_os_str().is_empty()) { + if let Some(dir) = &dir { dialog = dialog.set_directory(dir); } - let picked = dialog.save_file(); - Box::pin(async move { picked.await.map(|file| FileDialogAnswer::SaveTo(path_of(&file))) }) + // On Linux the portal's own dialog, which reports the file type picked (#6121); rfd if + // the portal can't show it. + #[cfg(target_os = "linux")] + { + use eframe::wgpu::rwh::HasWindowHandle; + let parent = crate::portal_save::parent_id(parent.and_then(|p| p.window_handle().ok()).map(|h| h.as_raw())); + let req = crate::portal_save::SaveRequest { + parent, + name: dialog_file_name(&suggested, true), + suggested: Path::new(&suggested).file_name().map(|n| n.to_string_lossy().into_owned()), + folder: dir, + filters, + current: Some(0), + }; + Box::pin(async move { + let portal = zbus::blocking::connection::Builder::session() + .and_then(|b| b.build()) + .map_err(|e| e.to_string()) + .and_then(|conn| crate::portal_save::save_as(&conn, req)); + match portal { + Ok(path) => path.map(|p| FileDialogAnswer::SaveTo(p.to_string_lossy().into_owned())), + Err(e) => { + log::info!("portal save dialog unavailable ({e}), using rfd"); + dialog.save_file().await.map(|file| FileDialogAnswer::SaveTo(path_of(&file))) + } + } + }) + } + #[cfg(not(target_os = "linux"))] + { + let _ = filters; + let picked = dialog.save_file(); + Box::pin(async move { picked.await.map(|file| FileDialogAnswer::SaveTo(path_of(&file))) }) + } } }; // Without the thread the reply is dropped unanswered, which the app takes as Cancel. @@ -585,6 +653,41 @@ mod tests { use photocraft_ui_egui::{PhotocraftApp, prefs_ui}; use serde_json::{Value, json}; + /// #6121: the file type picked in the save dialog gives the name its extension where the + /// dialog left the one it opened with, and only there. + #[test] + fn the_picked_file_type_gives_the_name_its_extension() { + let exts = |e: &[&str]| e.iter().map(|s| s.to_string()).collect::>(); + let (png, psd) = (exts(&["png"]), exts(&["psd", "psb"])); + let opened = Some("Untitled-1.psd"); + let cases = [ + // PNG picked, the name as the dialog opened (or renamed, its extension kept). + ("/pics/Untitled-1.psd", opened, &png, "/pics/Untitled-1.png"), + ("/pics/art.psd", opened, &png, "/pics/art.png"), + ("/pics/ART.PSD", opened, &png, "/pics/ART.png"), + // No extension: the type's. One PhotoCraft doesn't save is part of the name. + ("/pics/art", opened, &png, "/pics/art.png"), + ("/pics/scan.2024", opened, &png, "/pics/scan.2024.png"), + // Typed, an extension PhotoCraft saves is the user's: kept, whatever the type says + // (the PSD type untouched, or PNG picked and .jpg typed). + ("/pics/art.png", opened, &psd, "/pics/art.png"), + ("/pics/art.jpg", opened, &png, "/pics/art.jpg"), + ("/pics/art.pcraft", opened, &png, "/pics/art.pcraft"), + // Already the type's: kept, including its other extensions. + ("/pics/art.png", opened, &png, "/pics/art.png"), + ("/pics/art.PNG", opened, &png, "/pics/art.PNG"), + ("/pics/big.psb", opened, &psd, "/pics/big.psb"), + // The dialog opened on a .pcraft: that is the extension the type replaces. + ("/pics/art.pcraft", Some("art.pcraft"), &png, "/pics/art.png"), + ("/pics/art.psd", Some("art.pcraft"), &png, "/pics/art.psd"), + ]; + for (typed, opened, chosen, want) in cases { + assert_eq!(with_chosen_type(Path::new(typed), opened, chosen), PathBuf::from(want), "{typed} (opened {opened:?})"); + } + // No type picked: the name as typed. + assert_eq!(with_chosen_type(Path::new("/pics/art.psd"), opened, &[]), PathBuf::from("/pics/art.psd")); + } + #[cfg(all(unix, not(target_os = "macos")))] #[test] fn open_filters_cover_uppercase_and_mixed_case_extensions() { From d12be8ecd04cb7287467e26dbc82bc5b4de0c8c8 Mon Sep 17 00:00:00 2001 From: Tanish Manku Date: Thu, 15 Oct 2026 22:33:14 -0700 Subject: [PATCH 46/47] test(ui-egui): canvas_16f and rotation preview tests run on the app's wgpu device (#6454) Both harnesses built their device with egui_kittest's .wgpu() (egui's default limits) instead of gpu_canvas::wgpu_setup() (the adapter's own limits, what the app requests), as #5954 fixed for the background_redraw and navigator tests. On that device: - canvas_16f's sixteen_bit_gradient_on_screen and thirty_two_bit_preview_exposes_values_above_one panicked in egui-wgpu ("Failed to create staging buffer for index data", renderer.rs:984), every run, single-threaded too; - arbitrary_rotation_preview's two tests failed "the fixture must use the GPU canvas". Both binaries pass on the app's device. --- .../tests/arbitrary_rotation_preview.rs | 21 ++++++++++------ crates/ui-egui/tests/canvas_16f.rs | 25 +++++++++++-------- 2 files changed, 28 insertions(+), 18 deletions(-) diff --git a/crates/ui-egui/tests/arbitrary_rotation_preview.rs b/crates/ui-egui/tests/arbitrary_rotation_preview.rs index a3549e495c..388c941f16 100644 --- a/crates/ui-egui/tests/arbitrary_rotation_preview.rs +++ b/crates/ui-egui/tests/arbitrary_rotation_preview.rs @@ -11,14 +11,19 @@ type Harness = egui_kittest::Harness<'static, PhotocraftApp>; fn harness() -> Option { let built = std::panic::catch_unwind(|| { - egui_kittest::Harness::builder().with_size(egui::vec2(1280.0, 800.0)).with_pixels_per_point(1.0).with_max_steps(64).wgpu().build_eframe(|cc| { - PhotocraftApp::setup_context(&cc.egui_ctx, Default::default()); - let mut app = PhotocraftApp::new(photocraft_engine::Session::new(), Default::default()); - if let Some(rs) = cc.wgpu_render_state.as_ref() { - app.set_wgpu(rs.clone()); - } - app - }) + egui_kittest::Harness::builder() + .with_size(egui::vec2(1280.0, 800.0)) + .with_pixels_per_point(1.0) + .with_max_steps(64) + .wgpu_setup(photocraft_ui_egui::gpu_canvas::wgpu_setup()) + .build_eframe(|cc| { + PhotocraftApp::setup_context(&cc.egui_ctx, Default::default()); + let mut app = PhotocraftApp::new(photocraft_engine::Session::new(), Default::default()); + if let Some(rs) = cc.wgpu_render_state.as_ref() { + app.set_wgpu(rs.clone()); + } + app + }) }); match built { Ok(h) => Some(h), diff --git a/crates/ui-egui/tests/canvas_16f.rs b/crates/ui-egui/tests/canvas_16f.rs index 279a6dac6e..ff97cff680 100644 --- a/crates/ui-egui/tests/canvas_16f.rs +++ b/crates/ui-egui/tests/canvas_16f.rs @@ -181,16 +181,21 @@ fn screen(doc: Document, setup: &[(&str, serde_json::Value)], name: &str) -> Opt let ok2 = ok.clone(); let setup: Vec<(String, serde_json::Value)> = setup.iter().map(|(a, b)| (a.to_string(), b.clone())).collect(); let built = std::panic::catch_unwind(std::panic::AssertUnwindSafe(move || { - egui_kittest::Harness::builder().with_size(egui::vec2(1000.0, 700.0)).with_pixels_per_point(1.0).with_max_steps(64).wgpu().build_eframe(move |cc| { - PhotocraftApp::setup_context(&cc.egui_ctx, Default::default()); - let mut app = PhotocraftApp::new(photocraft_engine::Session::new(), Default::default()); - if let Some(rs) = cc.wgpu_render_state.as_ref() { - ok2.store(gpu_canvas::supports_f16_canvas(&rs.adapter) && std::env::var("PHOTOCRAFT_CANVAS_F16").as_deref() != Ok("0"), Ordering::SeqCst); - app.set_wgpu(rs.clone()); - } - app.session.open_document(doc, None); - app - }) + egui_kittest::Harness::builder() + .with_size(egui::vec2(1000.0, 700.0)) + .with_pixels_per_point(1.0) + .with_max_steps(64) + .wgpu_setup(gpu_canvas::wgpu_setup()) + .build_eframe(move |cc| { + PhotocraftApp::setup_context(&cc.egui_ctx, Default::default()); + let mut app = PhotocraftApp::new(photocraft_engine::Session::new(), Default::default()); + if let Some(rs) = cc.wgpu_render_state.as_ref() { + ok2.store(gpu_canvas::supports_f16_canvas(&rs.adapter) && std::env::var("PHOTOCRAFT_CANVAS_F16").as_deref() != Ok("0"), Ordering::SeqCst); + app.set_wgpu(rs.clone()); + } + app.session.open_document(doc, None); + app + }) })); let Ok(mut harness) = built else { eprintln!("skipping: no GPU adapter"); From 2a952f164c0b4cf936b1c6497db10dd095341c00 Mon Sep 17 00:00:00 2001 From: JGF <44821497+bommerts@users.noreply.github.com> Date: Fri, 16 Oct 2026 01:48:30 -0400 Subject: [PATCH 47/47] Windows/Linux: open files in the running PhotoCraft instead of a second instance (#3708) (#3755) Fixes #3708; part of #1540 (Flatpak needs its own follow-up: each sandbox has its own loopback). A second launch hands its files to the running instance over an authenticated loopback channel (random port, 32-byte token in a private instance.json, constant-time check, bounded clients, requests and paths). Files are acknowledged only once the UI has taken them; the instance shuts the hand-off down before a CPU-compatibility relaunch; it runs only in the per-user settings mode. --new-instance and PHOTOCRAFT_NEW_INSTANCE=1 opt out. Thanks to @MrCloudy2 (#2358), whose Linux version first handled the relaunch and per-user endpoints, and @Desayuno64 (#5273), whose shutdown flag and read deadline informed the final design. --- apps/photocraft/src/main.rs | 92 +++- apps/photocraft/src/single_instance.rs | 648 +++++++++++++++++++++++++ crates/ui-egui/src/file_open.rs | 17 +- crates/ui-egui/src/file_open/tests.rs | 22 + docs/development.md | 4 + 5 files changed, 776 insertions(+), 7 deletions(-) create mode 100644 apps/photocraft/src/single_instance.rs diff --git a/apps/photocraft/src/main.rs b/apps/photocraft/src/main.rs index 837332962f..c0f93bd319 100644 --- a/apps/photocraft/src/main.rs +++ b/apps/photocraft/src/main.rs @@ -3,7 +3,11 @@ //! Usage: `photocraft [--control ] [--control-token <64-hex> | //! --control-token-file ] [--automation-read-root ] //! [--automation-write-root ] [--safe-gpu] [--in-window-menus] [--present-mode ] -//! [--frame-latency ] [--stroke-trace ] [files…]` +//! [--frame-latency ] [--stroke-trace ] [--new-instance] [files…]` +//! +//! On Windows and Linux a launch hands its files to the PhotoCraft that is already running (new +//! tabs in its window) and exits; `--new-instance` starts a separate copy instead, as does any +//! `--control` launch (see `single_instance`). //! //! `--present-mode` (`fifo`, `autovsync`, `novsync`) and `--frame-latency` (1–3) override how //! frames reach the display for this launch (`PHOTOCRAFT_PRESENT_MODE` / `…_FRAME_LATENCY`; @@ -56,6 +60,9 @@ mod portal_save; mod presentation; mod screen_color; mod services; +// macOS gets one instance from LaunchServices (open-documents Apple events). +#[cfg(any(not(target_os = "macos"), test))] +mod single_instance; // Windows gets pen pressure from winit (WM_POINTER); the web runner has its own listener. #[cfg(any(target_os = "macos", target_os = "linux", test))] mod tablet; @@ -201,6 +208,9 @@ fn main() -> eframe::Result { // reported in a notice once the app is up; a control token is a usage error like a bad port. let mut unreadable_paths: Vec = Vec::new(); let mut safe_gpu = false; + let mut new_instance = false; + // Options that apply only to the process they're given to; a launch with any keeps its own. + let mut launch_options: Vec<&'static str> = Vec::new(); let (mut present_mode_arg, mut frame_latency_arg, mut stroke_trace_arg): (Option, Option, Option) = (None, None, None); let mut in_window_menus = std::env::var_os("PHOTOCRAFT_IN_WINDOW_MENUS").is_some_and(|v| !v.is_empty() && v != "0"); let mut args = std::env::args_os().skip(1); @@ -219,10 +229,24 @@ fn main() -> eframe::Result { None => control_token = None, }, Some("--control-token-file") => control_token_file = args.next().map(std::path::PathBuf::from), - Some("--automation-read-root") => automation_read_root = args.next().map(std::path::PathBuf::from), - Some("--automation-write-root") => automation_write_root = args.next().map(std::path::PathBuf::from), - Some("--safe-gpu") => safe_gpu = true, + Some("--automation-read-root") => { + launch_options.push("--automation-read-root"); + automation_read_root = args.next().map(std::path::PathBuf::from); + } + Some("--automation-write-root") => { + launch_options.push("--automation-write-root"); + automation_write_root = args.next().map(std::path::PathBuf::from); + } + Some("--safe-gpu") => { + launch_options.push("--safe-gpu"); + safe_gpu = true; + } Some(flag @ ("--present-mode" | "--frame-latency" | "--stroke-trace")) => { + launch_options.push(match flag { + "--present-mode" => "--present-mode", + "--frame-latency" => "--frame-latency", + _ => "--stroke-trace", + }); let flag = flag.to_string(); match args.next().map(std::ffi::OsString::into_string) { Some(Ok(v)) => match flag.as_str() { @@ -234,7 +258,11 @@ fn main() -> eframe::Result { _ => eprintln!("photocraft: {flag}: missing or unreadable value; ignored"), } } - Some("--in-window-menus") => in_window_menus = true, + Some("--in-window-menus") => { + launch_options.push("--in-window-menus"); + in_window_menus = true; + } + Some("--new-instance") => new_instance = true, Some("--version") => { println!("photocraft {}", photocraft_engine::build_info::long_version()); return Ok(()); @@ -266,6 +294,48 @@ fn main() -> eframe::Result { } } + // One PhotoCraft per user (#3708): files opened from Explorer or a file manager while it runs + // become tabs in its window. Only with the per-user settings folder (a portable copy's may be + // shared between users), and not for automation, `--new-instance` or per-process options. + #[cfg(not(target_os = "macos"))] + let single_instance = { + let new_instance_env = std::env::var("PHOTOCRAFT_NEW_INSTANCE").ok(); + let context = single_instance::LaunchContext { + per_user_settings: app_dirs::current().mode == app_dirs::Mode::Platform, + new_instance_flag: new_instance, + new_instance_env: new_instance_env.as_deref(), + control: control_port.is_some(), + unreadable_paths: !unreadable_paths.is_empty(), + launch_options: &launch_options, + }; + match (single_instance::skip_reason(&context), services::config_dir()) { + (Some(reason), _) => { + log::info!("running as a separate PhotoCraft: {reason}"); + None + } + (None, None) => None, + (None, Some(dir)) => { + let paths: Vec = files.iter().map(|f| single_instance::absolute(f)).collect(); + match single_instance::claim(&dir, &paths) { + single_instance::Launch::Forwarded => { + log::info!("handed {} file(s) to the running PhotoCraft", paths.len()); + return Ok(()); + } + single_instance::Launch::Primary(primary) => Some(primary), + single_instance::Launch::Standalone(reason) => { + log::warn!("starting a separate PhotoCraft: {reason}"); + None + } + } + } + } + }; + // Lives until the event loop returns, then `shutdown` lets the next launch become the primary. + #[cfg(not(target_os = "macos"))] + let single_instance = &single_instance; + #[cfg(target_os = "macos")] + let _ = (new_instance, &launch_options); + // The display server to open the window on: Xwayland for a pen on Wayland, which gives this // app no pen input (#639). #[cfg(target_os = "linux")] @@ -528,6 +598,11 @@ fn main() -> eframe::Result { } #[cfg(not(target_os = "macos"))] let _ = in_window_menus; + // Files from later launches (Explorer, file managers) open here as new tabs. + #[cfg(not(target_os = "macos"))] + if let Some(primary) = single_instance { + app.services.os_events = Some(primary.connect(&cc.egui_ctx)); + } // Where file drags and drops are (winit 0.30 doesn't say). app.services.cursor_pos = cursor::service(cc); // The custom title bar's undecorated window must stay borderless, or its caption and @@ -565,6 +640,13 @@ fn main() -> eframe::Result { Ok(Box::new(app)) }), ); + // The window is gone: refuse further files and free the lock, so a launch that is waiting (or + // the CPU-compatibility relaunch below) becomes the primary instead of handing its files to a + // process that is exiting. + #[cfg(not(target_os = "macos"))] + if let Some(primary) = single_instance { + primary.shutdown(); + } // Closed or failed outside graphics initialization: not a driver crash. A renderer error // keeps the marker, so the next start tries a safer backend, unless there is no safer one: // a failed CPU start would otherwise pin every later start to CPU. diff --git a/apps/photocraft/src/single_instance.rs b/apps/photocraft/src/single_instance.rs new file mode 100644 index 0000000000..d2cd65c678 --- /dev/null +++ b/apps/photocraft/src/single_instance.rs @@ -0,0 +1,648 @@ +//! One PhotoCraft per user on Windows and Linux (#3708): a file opened from Explorer or a file +//! manager lands as a new tab in the running window, like Photoshop, instead of starting a second +//! copy of the program. +//! +//! The first launch takes an exclusive OS lock on `instance.lock` in the settings directory (the OS +//! drops it when the process ends, crash included), listens on a loopback port and writes the port +//! plus a fresh 256-bit token to `instance.json`. A later launch that finds the lock taken sends its +//! paths there; the primary queues them for the UI as [`OsEvent::Open`], which opens each one and +//! brings the window forward. Explorer starts one process per file when several are opened at once; +//! the lock decides the race, so they all end up as tabs in one window. +//! +//! The primary answers `ok` only once the UI has taken the paths. A launch that hears anything else +//! (the primary is still starting, hung, or quitting) keeps retrying the lock, so it becomes the +//! primary itself as soon as the old one lets go, and when its time runs out it starts a separate +//! instance: a file is never acknowledged and then dropped. [`Primary::shutdown`] runs when the +//! event loop ends (and before the CPU-compatibility relaunch), so a closing PhotoCraft refuses new +//! files and frees the lock. +//! +//! Only for the per-user settings folder ([`skip_reason`]): a portable copy's folder beside the +//! executable, or a `PHOTOCRAFT_CONFIG_DIR`, may be shared between users. +//! macOS doesn't need this: LaunchServices already sends a running app an open-documents event. + +// Only the tests use it on macOS. +#![cfg_attr(target_os = "macos", allow(dead_code))] + +use std::fs::{File, OpenOptions}; +use std::io::{BufReader, Read, Write}; +use std::net::{Ipv4Addr, SocketAddr, TcpListener, TcpStream}; +use std::path::Path; +use std::sync::atomic::{AtomicBool, AtomicUsize, Ordering}; +use std::sync::mpsc::{SyncSender, sync_channel}; +use std::sync::{Arc, Mutex, PoisonError}; +use std::time::{Duration, Instant}; + +use photocraft_automation::security::{LineRead, generate_token, read_bounded_line, token_matches}; +use photocraft_ui_egui::{OsEvent, OsEventsFn}; +use serde_json::{Value, json}; + +const LOCK_FILE: &str = "instance.lock"; +const INFO_FILE: &str = "instance.json"; +/// More paths than this in one request is not a file manager. +const MAX_PATHS: usize = 4096; +/// Launches served at once; a stalled client holds only its own slot, never the next launch. +const MAX_CLIENTS: usize = 8; +/// Kept out of the hand-off's budget, so the primary answers before the launch gives up on it. +const REPLY_MARGIN: Duration = Duration::from_millis(500); + +/// How long each side waits (shortened in tests). +#[derive(Clone, Copy, Debug)] +pub struct Timing { + /// A later launch's whole budget for handing its paths over (or taking the lock over). + pub handoff: Duration, + /// How long the primary waits for its UI to take a launch's paths before refusing them. + pub ack: Duration, + /// Total time a connection gets to send its request line. + pub request: Duration, +} + +/// Generous enough for a primary that is still opening its window. +pub const TIMING: Timing = Timing { handoff: Duration::from_secs(10), ack: Duration::from_secs(8), request: Duration::from_secs(5) }; + +/// Outcome of [`claim`]. +pub enum Launch { + /// This process is the running instance; keep [`Primary`] alive and attach it to the app. + Primary(Primary), + /// The running instance took the paths; this process should exit. + Forwarded, + /// Couldn't do either (reason given): run as an independent instance. + Standalone(String), +} + +/// What decides whether a launch takes part (see [`skip_reason`]). +#[derive(Clone, Copy)] +pub struct LaunchContext<'a> { + /// The settings folder is the platform's per-user one (not portable, not an override). + pub per_user_settings: bool, + /// `--new-instance`. + pub new_instance_flag: bool, + /// `PHOTOCRAFT_NEW_INSTANCE`, if set. + pub new_instance_env: Option<&'a str>, + /// `--control` or `PHOTOCRAFT_CONTROL_PORT`: automation gets its own process. + pub control: bool, + /// Paths that aren't valid Unicode: this launch reports them. + pub unreadable_paths: bool, + /// Options that only apply to the process they're given to (`--safe-gpu`, …). + pub launch_options: &'a [&'static str], +} + +/// Why this launch runs as its own instance, or `None` to take part in single-instance. +pub fn skip_reason(c: &LaunchContext) -> Option { + if !c.per_user_settings { + return Some("the settings folder isn't the per-user one (portable or PHOTOCRAFT_CONFIG_DIR)".into()); + } + if c.new_instance_flag || c.new_instance_env.is_some_and(|v| !v.is_empty() && v != "0") { + return Some("--new-instance".into()); + } + if c.control { + return Some("automation (--control) runs its own process".into()); + } + if c.unreadable_paths { + return Some("some paths aren't valid Unicode".into()); + } + if !c.launch_options.is_empty() { + return Some(format!("{} only applies to a new process", c.launch_options.join(", "))); + } + None +} + +/// The running instance: holds the lock and receives other launches' paths. +pub struct Primary { + lock: Mutex>, + shared: Arc, +} + +struct Shared { + inbox: Mutex, + /// Set by [`Primary::shutdown`]: new connections are dropped, waiting ones refused. + closed: AtomicBool, +} + +#[derive(Default)] +struct Inbox { + pending: Vec, + ctx: Option, + next_id: u64, +} + +/// Paths waiting for the UI; `taken` tells the connection that sent them. +struct Pending { + id: u64, + paths: Vec, + taken: SyncSender<()>, +} + +impl Primary { + /// The queue the UI drains (see `Services::os_events`); a later launch wakes `ctx`. + pub fn connect(&self, ctx: &egui::Context) -> OsEventsFn { + let mut inbox = self.shared.inbox.lock().unwrap_or_else(PoisonError::into_inner); + if !inbox.pending.is_empty() { + ctx.request_repaint(); + } + inbox.ctx = Some(ctx.clone()); + drop(inbox); + let shared = Arc::clone(&self.shared); + Box::new(move || shared.take()) + } + + /// Stop taking files and release the lock: launches waiting on this one are refused and take + /// the lock over themselves. Call when the event loop has ended, and before relaunching. + pub fn shutdown(&self) { + self.shared.closed.store(true, Ordering::Release); + // Dropping the senders refuses every connection still waiting for the UI. + self.shared.inbox.lock().unwrap_or_else(PoisonError::into_inner).pending.clear(); + drop(self.lock.lock().unwrap_or_else(PoisonError::into_inner).take()); + } +} + +impl Drop for Primary { + fn drop(&mut self) { + self.shutdown(); + } +} + +impl Shared { + /// Everything queued, acknowledged to the senders while the inbox is locked (see `hand_over`). + fn take(&self) -> Vec { + let mut inbox = self.inbox.lock().unwrap_or_else(PoisonError::into_inner); + inbox + .pending + .drain(..) + .map(|p| { + let _ = p.taken.try_send(()); + OsEvent::Open(p.paths) + }) + .collect() + } + + /// Queue `paths` for the UI and wait up to `wait` for it to take them. False (and nothing left + /// queued) when it didn't: the sender then retries or starts its own instance. + fn hand_over(&self, paths: Vec, wait: Duration) -> bool { + let (taken, rx) = sync_channel(1); + let id = { + let mut inbox = self.inbox.lock().unwrap_or_else(PoisonError::into_inner); + if self.closed.load(Ordering::Acquire) { + return false; + } + let id = inbox.next_id; + inbox.next_id = id.wrapping_add(1); + inbox.pending.push(Pending { id, paths, taken }); + if let Some(ctx) = &inbox.ctx { + ctx.request_repaint(); + } + id + }; + if rx.recv_timeout(wait).is_ok() { + return true; + } + // Timed out or refused: withdraw, unless the UI took them in the meantime (it acknowledges + // while holding the lock, so the answer is settled here). + let mut inbox = self.inbox.lock().unwrap_or_else(PoisonError::into_inner); + if rx.try_recv().is_ok() { + return true; + } + inbox.pending.retain(|p| p.id != id); + false + } +} + +/// Become the running instance under `dir` or hand `paths` (absolute) to the one that is. +pub fn claim(dir: &Path, paths: &[String]) -> Launch { + claim_with(dir, paths, TIMING) +} + +/// [`claim`] with explicit timeouts. Until `timing.handoff` runs out, each round tries the lock +/// (free once a quitting or crashed primary is gone) and then the running primary. +pub fn claim_with(dir: &Path, paths: &[String], timing: Timing) -> Launch { + if let Err(e) = std::fs::create_dir_all(dir) { + return Launch::Standalone(format!("{}: {e}", dir.display())); + } + let lock_path = dir.join(LOCK_FILE); + let lock = match OpenOptions::new().read(true).write(true).create(true).truncate(false).open(&lock_path) { + Ok(lock) => lock, + Err(e) => return Launch::Standalone(format!("{}: {e}", lock_path.display())), + }; + let deadline = Instant::now() + timing.handoff; + let mut last = "no instance.json".to_string(); + loop { + match lock.try_lock() { + Ok(()) => { + return match serve(dir, lock, timing) { + Ok(primary) => Launch::Primary(primary), + Err(e) => Launch::Standalone(e), + }; + } + Err(std::fs::TryLockError::WouldBlock) => {} + Err(std::fs::TryLockError::Error(e)) => return Launch::Standalone(format!("{}: {e}", lock_path.display())), + } + if let Some((port, token)) = read_info(dir) { + match send(port, &token, paths, deadline) { + Ok(()) => return Launch::Forwarded, + Err(e) => last = e, + } + } + if Instant::now() >= deadline { + return Launch::Standalone(format!("the running PhotoCraft didn't take the files ({last})")); + } + std::thread::sleep(Duration::from_millis(100)); + } +} + +fn serve(dir: &Path, lock: File, timing: Timing) -> Result { + let listener = TcpListener::bind((Ipv4Addr::LOCALHOST, 0)).map_err(|e| format!("loopback listener: {e}"))?; + let port = listener.local_addr().map_err(|e| format!("loopback listener: {e}"))?.port(); + let token = generate_token().map_err(|e| e.to_string())?; + write_info(dir, port, &token)?; + let shared = Arc::new(Shared { inbox: Mutex::default(), closed: AtomicBool::new(false) }); + let listening = Arc::clone(&shared); + let spawned = std::thread::Builder::new().name("single-instance".into()).spawn(move || { + let token = Arc::new(token); + let clients = Arc::new(AtomicUsize::new(0)); + loop { + let stream = match listener.accept() { + Ok((stream, _)) => stream, + // Back off instead of spinning when accept keeps failing (e.g. out of descriptors). + Err(_) => { + std::thread::sleep(Duration::from_millis(100)); + continue; + } + }; + // Closing: the sender sees the connection drop and takes the lock over. + if listening.closed.load(Ordering::Acquire) { + continue; + } + // Each launch on its own thread, so one that connects and stalls doesn't hold the + // ones after it; past the cap a connection is dropped and that launch retries. + if clients.fetch_add(1, Ordering::AcqRel) >= MAX_CLIENTS { + clients.fetch_sub(1, Ordering::AcqRel); + continue; + } + let (token, shared, slot) = (Arc::clone(&token), Arc::clone(&listening), Arc::clone(&clients)); + let spawned = std::thread::Builder::new().name("single-instance-client".into()).spawn(move || { + serve_client(stream, &token, &shared, timing); + slot.fetch_sub(1, Ordering::AcqRel); + }); + if spawned.is_err() { + clients.fetch_sub(1, Ordering::AcqRel); + } + } + }); + spawned.map_err(|e| format!("single-instance listener thread: {e}"))?; + Ok(Primary { lock: Mutex::new(Some(lock)), shared }) +} + +/// Read one request, hand its paths to the UI and answer `ok` only if the UI took them. +fn serve_client(stream: TcpStream, token: &str, shared: &Shared, timing: Timing) { + let taken = match read_request(&stream, timing.request) { + Some(line) => match parse_request(&line, token) { + Some((paths, wait)) => shared.hand_over(paths, wait.min(timing.ack)), + None => false, + }, + None => return, + }; + let reply = if taken { json!({"ok": true}) } else { json!({"ok": false, "error": "not taken"}) }; + let mut out = stream; + let _ = out.set_write_timeout(Some(timing.request)); + let _ = writeln!(out, "{reply}").and_then(|()| out.flush()); +} + +/// A reader that fails once `until` passes, however slowly the bytes trickle in. +struct Deadline<'a> { + stream: &'a TcpStream, + until: Instant, +} + +impl Read for Deadline<'_> { + fn read(&mut self, buf: &mut [u8]) -> std::io::Result { + let left = self.until.checked_duration_since(Instant::now()).filter(|d| !d.is_zero()); + let left = left.ok_or_else(|| std::io::Error::new(std::io::ErrorKind::TimedOut, "request deadline passed"))?; + self.stream.set_read_timeout(Some(left))?; + let mut stream = self.stream; + stream.read(buf) + } +} + +/// The request line, if it arrives complete within `total`. +fn read_request(stream: &TcpStream, total: Duration) -> Option { + let mut reader = BufReader::new(Deadline { stream, until: Instant::now() + total }); + let mut line = String::new(); + matches!(read_bounded_line(&mut reader, &mut line), Ok(LineRead::Line)).then_some(line) +} + +/// Port and token, written to a temporary file and renamed so a reader never sees half of it. +/// On Unix the file is the user's alone (0600): another local user who read the token could make +/// this app open any path. (Windows keeps the per-user ACL of the settings folder.) +fn write_info(dir: &Path, port: u16, token: &str) -> Result<(), String> { + let path = dir.join(INFO_FILE); + let tmp = dir.join(format!("{INFO_FILE}.{}.tmp", std::process::id())); + let body = json!({"port": port, "token": token, "pid": std::process::id()}).to_string(); + // A leftover from a crash could carry wider permissions; the mode applies only on create. + let _ = std::fs::remove_file(&tmp); + let mut options = OpenOptions::new(); + options.write(true).create_new(true); + #[cfg(unix)] + std::os::unix::fs::OpenOptionsExt::mode(&mut options, 0o600); + let written = options.open(&tmp).and_then(|mut f| f.write_all(body.as_bytes())).and_then(|()| std::fs::rename(&tmp, &path)); + written.map_err(|e| { + let _ = std::fs::remove_file(&tmp); + format!("{}: {e}", path.display()) + }) +} + +fn read_info(dir: &Path) -> Option<(u16, String)> { + let v: Value = serde_json::from_slice(&std::fs::read(dir.join(INFO_FILE)).ok()?).ok()?; + let port = u16::try_from(v.get("port")?.as_u64()?).ok()?; + Some((port, v.get("token")?.as_str()?.to_string())) +} + +/// `{"token":…,"open":[…],"wait_ms":…}`: the paths, and how long the sender will wait for the UI. +fn parse_request(line: &str, token: &str) -> Option<(Vec, Duration)> { + let v: Value = serde_json::from_str(line).ok()?; + if !token_matches(token, v.get("token")?.as_str()?) { + return None; + } + let open = v.get("open")?.as_array()?; + if open.len() > MAX_PATHS { + return None; + } + let paths = open.iter().map(|p| p.as_str().map(str::to_string)).collect::>>()?; + let wait = Duration::from_millis(v.get("wait_ms").and_then(Value::as_u64).unwrap_or(0)); + Some((paths, wait)) +} + +/// Offer `paths` to the primary on `port`; `Ok` once its UI has taken them. +fn send(port: u16, token: &str, paths: &[String], deadline: Instant) -> Result<(), String> { + let left = deadline.checked_duration_since(Instant::now()).filter(|d| !d.is_zero()).ok_or("out of time")?; + let addr = SocketAddr::from((Ipv4Addr::LOCALHOST, port)); + let mut stream = TcpStream::connect_timeout(&addr, left).map_err(|e| e.to_string())?; + // The primary must answer before our deadline, so it waits a little less than we do. + let wait_ms = u64::try_from(left.saturating_sub(REPLY_MARGIN).as_millis()).unwrap_or(u64::MAX); + let request = json!({"token": token, "open": paths, "wait_ms": wait_ms}); + stream.set_write_timeout(Some(left)).map_err(|e| e.to_string())?; + writeln!(stream, "{request}").and_then(|()| stream.flush()).map_err(|e| e.to_string())?; + let mut reader = BufReader::new(Deadline { stream: &stream, until: deadline }); + let mut line = String::new(); + match read_bounded_line(&mut reader, &mut line) { + Ok(LineRead::Line) => {} + Ok(_) => return Err("no reply".into()), + Err(e) => return Err(e.to_string()), + } + let ok = serde_json::from_str::(&line).ok().and_then(|v| v.get("ok")?.as_bool()) == Some(true); + if ok { Ok(()) } else { Err("not taken".into()) } +} + +/// Absolute form of a command-line path, so the running instance (another working directory) +/// opens the same file. +pub fn absolute(path: &str) -> String { + std::path::absolute(path).map(|p| p.to_string_lossy().into_owned()).unwrap_or_else(|_| path.to_string()) +} + +#[cfg(test)] +mod tests { + use super::*; + use std::path::PathBuf; + use std::sync::mpsc::channel; + + const FAST: Timing = Timing { handoff: Duration::from_millis(1500), ack: Duration::from_millis(500), request: Duration::from_millis(800) }; + + fn temp_dir(name: &str) -> PathBuf { + let dir = std::env::temp_dir().join(format!("photocraft-single-instance-{name}-{}", std::process::id())); + let _ = std::fs::remove_dir_all(&dir); + dir + } + + /// Launch with `paths` on another thread (it blocks until the primary's UI takes them). + fn launch(dir: &Path, paths: &[&str], timing: Timing) -> std::thread::JoinHandle { + let (dir, paths): (PathBuf, Vec) = (dir.to_path_buf(), paths.iter().map(|p| p.to_string()).collect()); + std::thread::spawn(move || claim_with(&dir, &paths, timing)) + } + + /// Play the UI: drain until `n` events arrived (or 5 s passed). + fn drain(ui: &mut OsEventsFn, n: usize) -> Vec { + let mut got = Vec::new(); + let deadline = Instant::now() + Duration::from_secs(5); + while got.len() < n && Instant::now() < deadline { + got.extend(ui()); + std::thread::sleep(Duration::from_millis(10)); + } + got + } + + fn primary(dir: &Path) -> Primary { + match claim_with(dir, &[], FAST) { + Launch::Primary(p) => p, + _ => panic!("first launch should be primary"), + } + } + + #[test] + fn second_launch_hands_its_paths_to_the_first() { + let dir = temp_dir("handoff"); + let primary = primary(&dir); + let mut ui = primary.connect(&egui::Context::default()); + let paths = ["C:\\art\\one.psd", "/home/a/two é.png"]; + let second = launch(&dir, &paths, TIMING); + assert_eq!(drain(&mut ui, 1), vec![OsEvent::Open(paths.iter().map(|p| p.to_string()).collect())]); + assert!(matches!(second.join().unwrap(), Launch::Forwarded)); + // A plain second start (no files) just brings the window forward. + let third = launch(&dir, &[], TIMING); + assert_eq!(drain(&mut ui, 1), vec![OsEvent::Open(Vec::new())]); + assert!(matches!(third.join().unwrap(), Launch::Forwarded)); + drop(primary); + let _ = std::fs::remove_dir_all(&dir); + } + + #[test] + fn simultaneous_launches_all_land_in_one_instance() { + let dir = temp_dir("race"); + let (tx, rx) = channel(); + for i in 0..6 { + let (dir, tx) = (dir.clone(), tx.clone()); + std::thread::spawn(move || { + let _ = tx.send(claim(&dir, &[format!("file{i}.psd")])); + }); + } + drop(tx); + let (mut primaries, mut forwarded, mut events) = (Vec::new(), 0, Vec::new()); + let mut ui: Option = None; + let deadline = Instant::now() + Duration::from_secs(10); + while primaries.len() + forwarded < 6 && Instant::now() < deadline { + while let Ok(launch) = rx.try_recv() { + match launch { + Launch::Primary(p) => primaries.push(p), + Launch::Forwarded => forwarded += 1, + Launch::Standalone(reason) => panic!("standalone: {reason}"), + } + } + if ui.is_none() + && let Some(p) = primaries.first() + { + ui = Some(p.connect(&egui::Context::default())); + } + if let Some(ui) = ui.as_mut() { + events.extend(ui()); + } + std::thread::sleep(Duration::from_millis(10)); + } + assert_eq!((primaries.len(), forwarded, events.len()), (1, 5, 5)); + drop(primaries); + let _ = std::fs::remove_dir_all(&dir); + } + + #[test] + fn a_crashed_instance_leaves_nothing_in_the_way() { + let dir = temp_dir("stale"); + std::fs::create_dir_all(&dir).unwrap(); + // instance.json from a process that is gone; the lock is free. + std::fs::write(dir.join(INFO_FILE), r#"{"port":1,"token":"x","pid":1}"#).unwrap(); + let primary = primary(&dir); + assert!(read_info(&dir).is_some_and(|(port, _)| port != 1)); + drop(primary); + // Released with the handle, as on process exit. + assert!(matches!(claim_with(&dir, &[], FAST), Launch::Primary(_))); + let _ = std::fs::remove_dir_all(&dir); + } + + /// Review of #3755, point 1 (and the quitting half of point 3): a launch waiting on a primary + /// that shuts down (closing, or about to relaunch itself in CPU mode) isn't told `ok`; it takes + /// the lock over and opens the file itself. + #[test] + fn a_launch_waiting_on_a_closing_instance_takes_over() { + let dir = temp_dir("closing"); + let primary = primary(&dir); + // The UI never drains (it is shutting down, or never came up). + let waiting = launch(&dir, &["a.psd"], FAST); + std::thread::sleep(Duration::from_millis(200)); + primary.shutdown(); + assert!(matches!(waiting.join().unwrap(), Launch::Primary(_)), "the waiting launch becomes the primary"); + // After shutdown new connections are refused too. + let (port, token) = read_info(&dir).unwrap(); + drop(primary); + let short = Instant::now() + Duration::from_millis(300); + assert!(send(port, &token, &["b.psd".into()], short).is_err()); + let _ = std::fs::remove_dir_all(&dir); + } + + /// Review of #3755, point 3: a primary whose UI never takes the paths (hung) doesn't + /// acknowledge them, and doesn't keep them queued to open later as well. + #[test] + fn a_hung_instance_never_swallows_a_file() { + let dir = temp_dir("hung-ui"); + let primary = primary(&dir); + let mut ui = primary.connect(&egui::Context::default()); + let started = Instant::now(); + assert!(matches!(claim_with(&dir, &["a.psd".into()], FAST), Launch::Standalone(_))); + assert!(started.elapsed() < FAST.handoff + Duration::from_secs(1), "{:?}", started.elapsed()); + assert!(ui().is_empty(), "withdrawn, not opened later"); + drop(primary); + let _ = std::fs::remove_dir_all(&dir); + } + + #[test] + fn requests_without_the_token_or_malformed_are_rejected() { + let token = generate_token().unwrap(); + let ok = parse_request(&json!({"token": token, "open": ["a"], "wait_ms": 250}).to_string(), &token); + assert_eq!(ok, Some((vec!["a".to_string()], Duration::from_millis(250)))); + let wrong = generate_token().unwrap(); + assert_eq!(parse_request(&json!({"token": wrong, "open": ["a"]}).to_string(), &token), None); + for bad in ["", "not json", "{}", r#"{"token":5}"#, &json!({"token": token}).to_string(), &json!({"token": token, "open": [1]}).to_string()] { + assert_eq!(parse_request(bad, &token), None, "{bad:?}"); + } + let many: Vec = (0..=MAX_PATHS).map(|i| i.to_string()).collect(); + assert_eq!(parse_request(&json!({"token": token, "open": many}).to_string(), &token), None); + } + + #[test] + fn a_stalled_client_does_not_hold_up_the_next_launch() { + let dir = temp_dir("stalled"); + let primary = primary(&dir); + let mut ui = primary.connect(&egui::Context::default()); + let (port, _) = read_info(&dir).unwrap(); + // Connects and never sends a line (each would otherwise hold the listener). + let stalled: Vec = (0..3).map(|_| TcpStream::connect((Ipv4Addr::LOCALHOST, port)).unwrap()).collect(); + let started = Instant::now(); + let next = launch(&dir, &["b.psd"], FAST); + assert_eq!(drain(&mut ui, 1), vec![OsEvent::Open(vec!["b.psd".into()])]); + assert!(matches!(next.join().unwrap(), Launch::Forwarded)); + assert!(started.elapsed() < FAST.request, "took {:?}", started.elapsed()); + drop(stalled); + drop(primary); + let _ = std::fs::remove_dir_all(&dir); + } + + /// A client that trickles a byte at a time can't keep its slot past the request deadline. + #[test] + fn a_trickling_client_is_cut_off_at_the_request_deadline() { + let dir = temp_dir("trickle"); + let primary = primary(&dir); + let (port, _) = read_info(&dir).unwrap(); + let mut slow = TcpStream::connect((Ipv4Addr::LOCALHOST, port)).unwrap(); + let started = Instant::now(); + let mut cut_off = false; + while started.elapsed() < FAST.request * 4 { + if slow.write_all(b"{").is_err() { + cut_off = true; + break; + } + std::thread::sleep(Duration::from_millis(100)); + } + assert!(cut_off, "still connected after {:?}", started.elapsed()); + assert!(started.elapsed() < FAST.request * 3, "{:?}", started.elapsed()); + drop(primary); + let _ = std::fs::remove_dir_all(&dir); + } + + #[cfg(unix)] + #[test] + fn the_token_file_is_private_to_the_user() { + use std::os::unix::fs::PermissionsExt; + let dir = temp_dir("mode"); + std::fs::create_dir_all(&dir).unwrap(); + // A wide leftover temporary file from a crashed start doesn't widen the new one. + let tmp = dir.join(format!("{INFO_FILE}.{}.tmp", std::process::id())); + std::fs::write(&tmp, "{}").unwrap(); + std::fs::set_permissions(&tmp, std::fs::Permissions::from_mode(0o644)).unwrap(); + write_info(&dir, 1, &generate_token().unwrap()).unwrap(); + let mode = std::fs::metadata(dir.join(INFO_FILE)).unwrap().permissions().mode() & 0o777; + assert_eq!(mode, 0o600); + let _ = std::fs::remove_dir_all(&dir); + } + + #[test] + fn an_unreachable_instance_falls_back_to_a_standalone_launch() { + let dir = temp_dir("unreachable"); + std::fs::create_dir_all(&dir).unwrap(); + // Someone holds the lock but nothing listens on the advertised port. + let held = File::create(dir.join(LOCK_FILE)).unwrap(); + held.lock().unwrap(); + let closed = TcpListener::bind((Ipv4Addr::LOCALHOST, 0)).unwrap().local_addr().unwrap().port(); + write_info(&dir, closed, &generate_token().unwrap()).unwrap(); + assert!(matches!(claim_with(&dir, &["a.psd".into()], FAST), Launch::Standalone(_))); + drop(held); + let _ = std::fs::remove_dir_all(&dir); + } + + /// Review of #3755, point 2 and the smaller points: who takes part in single-instance. + #[test] + fn only_per_user_launches_without_process_options_take_part() { + let base = LaunchContext { + per_user_settings: true, + new_instance_flag: false, + new_instance_env: None, + control: false, + unreadable_paths: false, + launch_options: &[], + }; + assert_eq!(skip_reason(&base), None); + // A portable copy's folder or a PHOTOCRAFT_CONFIG_DIR may be shared between users. + assert!(skip_reason(&LaunchContext { per_user_settings: false, ..base }).is_some()); + assert!(skip_reason(&LaunchContext { new_instance_flag: true, ..base }).is_some()); + assert!(skip_reason(&LaunchContext { new_instance_env: Some("1"), ..base }).is_some()); + assert_eq!(skip_reason(&LaunchContext { new_instance_env: Some("0"), ..base }), None); + assert_eq!(skip_reason(&LaunchContext { new_instance_env: Some(""), ..base }), None); + assert!(skip_reason(&LaunchContext { control: true, ..base }).is_some()); + assert!(skip_reason(&LaunchContext { unreadable_paths: true, ..base }).is_some()); + let reason = skip_reason(&LaunchContext { launch_options: &["--safe-gpu"], ..base }); + assert!(reason.is_some_and(|r| r.contains("--safe-gpu"))); + } +} diff --git a/crates/ui-egui/src/file_open.rs b/crates/ui-egui/src/file_open.rs index d67e56dbf0..13105a321d 100644 --- a/crates/ui-egui/src/file_open.rs +++ b/crates/ui-egui/src/file_open.rs @@ -262,8 +262,21 @@ impl PhotocraftApp { match e { OsEvent::Open(paths) => { self.open_paths(&paths); - // Bring the window forward when another app (Finder, the Dock) asked us to open. - ctx.send_viewport_cmd(egui::ViewportCommand::Focus); + // Bring the window forward when another app (Finder, the Dock, Explorer via a + // second launch) asked us to open; restore it first if it was minimized. + if ctx.input(|i| i.viewport().minimized) == Some(true) { + ctx.send_viewport_cmd(egui::ViewportCommand::Minimized(false)); + } + // Native Wayland lets a window come forward only with the launcher's activation + // token, which winit can't apply to an existing window (its focus is a no-op + // there): mark the window as wanting attention instead, so the taskbar + // highlights it rather than the file opening out of sight. + let raise = if self.services.is_wayland { + egui::ViewportCommand::RequestUserAttention(egui::UserAttentionType::Informational) + } else { + egui::ViewportCommand::Focus + }; + ctx.send_viewport_cmd(raise); ctx.request_repaint(); } OsEvent::Quit => ctx.send_viewport_cmd(egui::ViewportCommand::Close), diff --git a/crates/ui-egui/src/file_open/tests.rs b/crates/ui-egui/src/file_open/tests.rs index b1eb1e7b72..4d6581cd6d 100644 --- a/crates/ui-egui/src/file_open/tests.rs +++ b/crates/ui-egui/src/file_open/tests.rs @@ -317,6 +317,28 @@ fn os_open_events_open_files_with_paths() { let _ = std::fs::remove_dir_all(&dir); } +/// The viewport commands an `Open` event sends: focus, or an attention request on native Wayland +/// where winit can't focus (#3708). +fn raise_commands(is_wayland: bool) -> Vec { + let (mut app, _) = app_with(Vec::new()); + app.services.is_wayland = is_wayland; + let mut queue = vec![OsEvent::Open(Vec::new())]; + app.services.os_events = Some(Box::new(move || std::mem::take(&mut queue))); + let mut out = egui::Context::default().run_ui(egui::RawInput::default(), |ui| app.drain_os_events(ui.ctx())); + out.textures_delta.clear(); + out.viewport_output.get(&egui::ViewportId::ROOT).map(|v| v.commands.clone()).unwrap_or_default() +} + +#[test] +fn an_os_open_brings_the_window_forward_or_asks_for_attention_on_wayland() { + use egui::{UserAttentionType, ViewportCommand}; + let attention = ViewportCommand::RequestUserAttention(UserAttentionType::Informational); + let x11 = raise_commands(false); + assert!(x11.contains(&ViewportCommand::Focus) && !x11.contains(&attention), "{x11:?}"); + let wayland = raise_commands(true); + assert!(wayland.contains(&attention) && !wayland.contains(&ViewportCommand::Focus), "{wayland:?}"); +} + #[test] fn os_quit_and_empty_events_do_not_panic() { let (mut app, _) = app_with(Vec::new()); diff --git a/docs/development.md b/docs/development.md index 3bfe4095e5..67f688b6b9 100644 --- a/docs/development.md +++ b/docs/development.md @@ -80,6 +80,10 @@ CRAFT_FONTS_DIR="$PWD/../craft-fonts" cargo test --workspace # runs the Japa - **Web:** the wasm32 build never embeds craft-fonts, even with `CRAFT_FONTS_DIR` set: measured on 2026-10-06, the UI face alone took the release wasm from 24.19 MB to 28.87 MB, over the 24 MiB gate in `packaging/web/package.sh`. Instead it loads fonts served next to it, on demand: `fonts/manifest.txt` beside `index.html`, in craft-fonts' manifest format (`crates/text/src/served.rs`; hosting: `packaging/web/README.md` › Fonts). `PHOTOCRAFT_WEB_FONTS_DIR="$PWD/../craft-fonts" packaging/web/package.sh` puts craft-fonts there. Served families are in the font menus and are fetched when picked or when a document's text needs them; they aren't in the script fallback list yet, so Japanese text set in a Latin font still needs a Japanese font picked (#1615). - Tests that need the fonts skip with a message when `CRAFT_FONTS` is empty; CI runs them again with `CRAFT_FONTS_DIR` set, in its own Linux job (`test (with craft-fonts)`). Desktop releases check out craft-fonts at the commit pinned in `.github/workflows/release.yml` (`CRAFT_FONTS_REF`; ci.yml pins the same commit) and ship each font's `OFL.txt` as `OFL-.txt`. +## One instance per user + +On Windows and Linux the first PhotoCraft holds an exclusive lock on `instance.lock` in the config directory and listens on a loopback port named in `instance.json` (with a fresh 256-bit token; the file is 0600 on Unix). A later launch hands its file arguments there and exits, so files opened from Explorer or a file manager become tabs in the running window (#3708). The primary answers `ok` only once its UI has taken the files; a launch that gets anything else (the primary is starting, hung or closing) keeps retrying the lock for up to 10 s, becoming the primary itself once the old one lets go, and then starts a separate instance, so a file is never acknowledged and lost. When the event loop ends (and before the CPU-compatibility relaunch) the primary refuses new files and frees the lock. It applies only with the per-user settings folder: a portable copy (`PhotoCraftData` beside the executable) or `PHOTOCRAFT_CONFIG_DIR` may be shared between users, so those launches run separately. So do `--new-instance` (or `PHOTOCRAFT_NEW_INSTANCE=1`), every `--control` launch, and launches given a per-process option (`--safe-gpu`, `--present-mode`, `--frame-latency`, `--stroke-trace`, `--in-window-menus`, `--automation-*-root`). A `cargo run` while an installed PhotoCraft is running forwards to it: pass `--new-instance` or set `PHOTOCRAFT_CONFIG_DIR`. macOS gets the same behaviour from LaunchServices (`apple_events.rs`). On native Wayland the running window can't bring itself forward (that takes the launcher's `XDG_ACTIVATION_TOKEN`, and winit 0.30 can't activate an existing window with one), so it asks for attention instead and the taskbar highlights it; X11, XWayland and Windows bring it to the front. Flatpak needs its own follow-up (#1540). Code: `apps/photocraft/src/single_instance.rs`. + ## Graphics startup and device loss `--safe-gpu` starts with the CPU renderer for one launch (no GPU canvas; a software adapter for the window where the platform has one: WARP on Windows, llvmpipe over GL on Linux). Before creating the wgpu device the app writes and locks `gpu-starting.json` in the config directory; it clears it once the first frames have rendered. A launch that finds an unlocked marker knows the previous start died inside the graphics driver (#4) and uses the next safer backend (Windows: Vulkan → DX12 → CPU; Linux: Vulkan → GL → CPU; macOS: Metal → CPU), remembering a working hardware backend in `performance.gpuBackend` (Preferences › Performance › GPU Backend, with **Reset GPU Backend**). CPU recovery is temporary until **Keep Using CPU** is chosen in the recovery dialog; dismissing the dialog or quitting without a choice preserves the previous GPU preference (#3483). With `auto`, Intel adapters on Windows use DX12. Help › System Info shows the adapter, backend, driver and fallback state.