From b9aa935d193b353f9349ba6fb469b7036211f30f Mon Sep 17 00:00:00 2001 From: Ivan Date: Sun, 11 Oct 2026 01:41:45 +0300 Subject: [PATCH 01/85] 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/85] 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/85] 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/85] 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/85] 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/85] 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/85] 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/85] 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/85] 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/85] 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/85] 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/85] 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/85] 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/85] 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/85] 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/85] 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 0cc161e2a85ba01fcc2e92fb82cc5267ea280374 Mon Sep 17 00:00:00 2001 From: "Dylan M. Taylor" Date: Thu, 15 Oct 2026 19:53:32 -0400 Subject: [PATCH 17/85] 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 18/85] 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 19/85] 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 20/85] 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 21/85] 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 22/85] 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 23/85] 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 24/85] 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. From 87918a6c850b7a3f89f2b2bd84b1c648b0ed0517 Mon Sep 17 00:00:00 2001 From: aiongg Date: Fri, 16 Oct 2026 16:53:13 +0000 Subject: [PATCH 25/85] Linked smart objects: follow stored paths only to local files or the document's share A linked smart object's path comes from the document. Every place that follows it now goes through one resolver (photocraft_io::doc_path, built on craft-docpath): a relative path is taken from the document's folder, and only local files or files on the share the document itself is on are opened. Paths to other computers, device paths and URLs are treated like a missing file: the layer keeps its last rendering. - Link watch: such links are not stamped, and no background look starts when no link of the document is followed. - Update Modified Content and other re-renders read only followed files; the error names why a link is not followed. - Package lists such links as missing instead of copying them. - Reveal in Finder for a linked layer shows only a followed file; the document's own Reveal is unchanged. The macOS invocation ends options with `--`, and Explorer is not asked to select a path containing a comma or a quotation mark. - Place Linked stores a relative path as an absolute one. The document path travels to smart-object renders in a thread-local scope entered by Session::edit, command dispatch and background jobs. Co-authored-by: David Mann --- Cargo.lock | 5 + Cargo.toml | 1 + crates/engine/src/doc_path.rs | 58 +++++++++ crates/engine/src/doc_path/tests.rs | 129 +++++++++++++++++++++ crates/engine/src/file_cmds.rs | 11 +- crates/engine/src/file_cmds/tests.rs | 2 +- crates/engine/src/jobs.rs | 7 ++ crates/engine/src/layer_menu_cmds.rs | 32 +++-- crates/engine/src/layer_menu_cmds/tests.rs | 39 ++++++- crates/engine/src/lib.rs | 2 + crates/engine/src/link_watch.rs | 32 +++-- crates/engine/src/print_cmds.rs | 13 ++- crates/engine/src/smart_cmds.rs | 17 ++- crates/io/Cargo.toml | 1 + crates/io/src/doc_path.rs | 59 ++++++++++ crates/io/src/lib.rs | 1 + 16 files changed, 377 insertions(+), 32 deletions(-) create mode 100644 crates/engine/src/doc_path.rs create mode 100644 crates/engine/src/doc_path/tests.rs create mode 100644 crates/io/src/doc_path.rs diff --git a/Cargo.lock b/Cargo.lock index 138ca3d814..e1206c2277 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1222,6 +1222,10 @@ dependencies = [ "libc", ] +[[package]] +name = "craft-docpath" +version = "0.1.0" + [[package]] name = "craft-print" version = "0.1.0" @@ -4869,6 +4873,7 @@ name = "photocraft-io" version = "0.9.0" dependencies = [ "blake3", + "craft-docpath", "crc32fast", "flate2", "photocraft-affinity", diff --git a/Cargo.toml b/Cargo.toml index 0d0f93ae7c..f215b0d049 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -39,6 +39,7 @@ photocraft-tablet = { path = "crates/tablet" } # external craft-print = { git = "https://github.com/storytold/craft-libs", rev = "3d37da389a3618ab9b76109da11416e9ca4ea719" } +craft-docpath = { path = "../secfix-craft-libs/crates/docpath" } thiserror = "2" serde = { version = "1", features = ["derive", "rc"] } serde_json = "1" diff --git a/crates/engine/src/doc_path.rs b/crates/engine/src/doc_path.rs new file mode 100644 index 0000000000..b51d164e74 --- /dev/null +++ b/crates/engine/src/doc_path.rs @@ -0,0 +1,58 @@ +//! Paths stored in a document reach the file system only through [`resolve`] (built on +//! [`photocraft_io::doc_path::resolve`]): linked smart objects, the Displace map and Color Lookup +//! file a smart filter re-reads, data-set images and video footage. A refused path is handled like +//! a missing file: the layer keeps its last rendering, and commands that need the file say why. +//! +//! Smart-object rendering has no `Session` argument, so the path of the document being worked on +//! travels in a thread-local scope, entered where commands run ([`crate::Session::edit`], command +//! dispatch, background jobs). Outside a scope there is no document: relative paths are refused +//! and only local absolute paths are followed. + +use std::cell::RefCell; +use std::path::{Path, PathBuf}; + +pub use photocraft_io::doc_path::NotFollowed; + +thread_local! { + static DOCUMENT: RefCell> = const { RefCell::new(None) }; +} + +/// Restores the previous document path when dropped. +pub(crate) struct DocumentScope(Option); + +impl Drop for DocumentScope { + fn drop(&mut self) { + let previous = self.0.take(); + DOCUMENT.with(|d| *d.borrow_mut() = previous); + } +} + +/// Resolve stored paths against `document` (the path of the document being worked on, `None` +/// for one without a file) until the returned scope is dropped. +pub(crate) fn scope(document: Option<&Path>) -> DocumentScope { + let next = document.filter(|d| !d.as_os_str().is_empty()).map(Path::to_path_buf); + DocumentScope(DOCUMENT.with(|d| d.replace(next))) +} + +/// The document path of the current scope (to carry it to another thread). +pub(crate) fn current() -> Option { + DOCUMENT.with(|d| d.borrow().clone()) +} + +/// The file to open for `stored`, a path stored in the document of the current scope. +pub fn resolve(stored: &str) -> Result { + resolve_in(stored, current().as_deref()) +} + +/// The file to open for `stored`, a path stored in the document saved at `document`. +pub fn resolve_in(stored: &str, document: Option<&Path>) -> Result { + photocraft_io::doc_path::resolve(stored, document) +} + +/// The message for a stored path that is not followed. +pub(crate) fn refusal(what: &str, stored: &str, why: NotFollowed) -> crate::EngineError { + crate::EngineError::Other(format!("{what} {stored} is not opened: {why}")) +} + +#[cfg(test)] +mod tests; diff --git a/crates/engine/src/doc_path/tests.rs b/crates/engine/src/doc_path/tests.rs new file mode 100644 index 0000000000..6b8657911f --- /dev/null +++ b/crates/engine/src/doc_path/tests.rs @@ -0,0 +1,129 @@ +//! Each kind of place that follows a path stored in a document goes through [`super::resolve`]: a +//! link to another computer is never followed, and a relative link next to the document is. + +use std::path::{Path, PathBuf}; + +use photocraft_doc::{LayerContent, LayerId, SmartSource}; +use photocraft_geom::Rect; +use serde_json::json; + +use crate::Session; + +/// A link to a share on a TEST-NET-1 address, which never answers. +const REMOTE: &str = r"\\192.0.2.1\share\x.png"; + +/// An empty folder of its own (tests run in parallel). +fn dir(name: &str) -> PathBuf { + let d = std::env::temp_dir().join(format!("photocraft-doc-path-{name}-{}", std::process::id())); + let _ = std::fs::remove_dir_all(&d); + std::fs::create_dir_all(&d).unwrap(); + d +} + +/// A 32×32 PNG whose pixel at column `x` is `rgb(x)`. +fn png(path: &Path, rgb: impl Fn(u32) -> [u8; 3]) { + let mut px = Vec::new(); + for _ in 0..32 { + for x in 0..32 { + px.extend(rgb(x)); + } + } + let image = photocraft_codecs::Image::from_u8(32, 32, photocraft_codecs::ChannelLayout::Rgb, px).unwrap(); + std::fs::write(path, photocraft_codecs::encode(&image, photocraft_codecs::Format::Png, &Default::default()).unwrap()).unwrap(); +} + +/// On Unix `//` followed by an absolute path names that same local file, while the resolver sees a +/// path on another computer: a place that skipped the resolver would read the file. +#[cfg(unix)] +fn network_alias(p: &Path) -> String { + format!("/{}", p.display()) +} + +/// A 32×32 white document saved (as far as its links know) at `d/doc.pcraft`, with `file` placed +/// linked → (session, layer id). +fn document(d: &Path, file: &Path) -> (Session, LayerId) { + let mut s = Session::new(); + s.execute("file.new", json!({"width": 32, "height": 32})).unwrap(); + s.active_mut().unwrap().path = Some(d.join("doc.pcraft").to_string_lossy().into_owned()); + s.execute("file.placeLinked", json!({"path": file.to_string_lossy()})).unwrap(); + let id = s.active().unwrap().active_layer.unwrap(); + (s, id) +} + +/// Point smart object `id` at `path` without re-rendering it. +fn relink(s: &mut Session, id: LayerId, path: &str) { + s.edit("Relink", |doc, _| { + if let Some(LayerContent::Smart(sm)) = doc.layer_mut(id).map(|l| &mut l.content) { + sm.source = SmartSource::Linked { path: path.into() }; + } + Ok(()) + }) + .unwrap(); +} + +fn centre(s: &Session) -> [f32; 3] { + let p = photocraft_compose::render(&s.active().unwrap().doc, Rect::new(16, 16, 17, 17)).px[0]; + [p[0], p[1], p[2]] +} + +const RED: [f32; 3] = [1.0, 0.0, 0.0]; +const BLUE: [f32; 3] = [0.0, 0.0, 1.0]; + +fn update(s: &mut Session) -> Result { + s.execute("layer.smartObjects.updateModifiedContent", json!({})).map_err(|e| e.to_string()) +} + +#[test] +fn linked_smart_objects_follow_only_local_files() { + let d = dir("links"); + png(&d.join("red.png"), |_| [255, 0, 0]); + png(&d.join("blue.png"), |_| [0, 0, 255]); + let (mut s, id) = document(&d, &d.join("red.png")); + assert_eq!(centre(&s), RED); + + relink(&mut s, id, REMOTE); + s.start_link_scan(); + assert!(s.link_scan.is_none(), "no look at a file on another computer"); + assert_eq!(s.execute("layer.smartObjects.updateChanged", json!({})).unwrap(), json!({"updated": []})); + let err = update(&mut s).unwrap_err(); + assert!(err.contains("is not opened: the path is on another computer"), "{err}"); + assert_eq!(centre(&s), RED, "the layer keeps its rendering"); + #[cfg(unix)] + { + relink(&mut s, id, &network_alias(&d.join("blue.png"))); + assert!(update(&mut s).is_err()); + assert_eq!(centre(&s), RED, "the file was not read"); + } + + // A relative link is taken from the document's folder. + relink(&mut s, id, "blue.png"); + update(&mut s).unwrap(); + assert_eq!(centre(&s), BLUE); + s.start_link_scan(); + assert!(s.link_scan.is_some(), "a followed link is looked at"); +} + +#[test] +fn package_copies_only_followed_links() { + let d = dir("package"); + png(&d.join("red.png"), |_| [255, 0, 0]); + let (mut s, id) = document(&d, &d.join("red.png")); + relink(&mut s, id, "red.png"); + #[allow(unused_mut)] + let mut remote = vec![REMOTE.to_string()]; + #[cfg(unix)] + remote.push(network_alias(&d.join("red.png"))); + for path in &remote { + s.execute("file.placeLinked", json!({"path": d.join("red.png").to_string_lossy()})).unwrap(); + let other = s.active().unwrap().active_layer.unwrap(); + relink(&mut s, other, path); + } + let r = s.execute("file.package", json!({"dir": d.join("pkg").to_string_lossy()})).unwrap(); + let mut missing: Vec = serde_json::from_value(r["missing"].clone()).unwrap(); + missing.sort(); + remote.sort(); + assert_eq!(missing, remote); + let links = r["links"].as_array().unwrap(); + assert_eq!(links.len(), 1); + assert!(links[0].as_str().unwrap().ends_with("red.png"), "{links:?}"); +} diff --git a/crates/engine/src/file_cmds.rs b/crates/engine/src/file_cmds.rs index 43828b76b1..d9eb4d10c0 100644 --- a/crates/engine/src/file_cmds.rs +++ b/crates/engine/src/file_cmds.rs @@ -578,7 +578,16 @@ fn place(s: &mut Session, p: &Value, linked: bool) -> Result { let cmd = if linked { "file.placeLinked" } else { "file.placeEmbedded" }; let path = str_param(p, "path", cmd)?.to_string(); let bytes = read_file(&path)?; - place_bytes(s, &path, bytes, linked.then(|| path.clone()), p) + // The link is stored absolute: a relative one would later be taken from the document's + // folder (`doc_path`), not the working directory the path was given in. + let link = linked.then(|| { + if std::path::Path::new(&path).is_relative() { + std::path::absolute(&path).map_or_else(|_| path.clone(), |a| a.to_string_lossy().into_owned()) + } else { + path.clone() + } + }); + place_bytes(s, &path, bytes, link, p) } // ---------- File Info (XMP) ---------- diff --git a/crates/engine/src/file_cmds/tests.rs b/crates/engine/src/file_cmds/tests.rs index 3cc72c30c4..624edfdd3f 100644 --- a/crates/engine/src/file_cmds/tests.rs +++ b/crates/engine/src/file_cmds/tests.rs @@ -1033,7 +1033,7 @@ fn reveal_in_finder_reveals_the_documents_saved_path() { let path = "/tmp/pics/a.psd"; s.active_mut().unwrap().path = Some(path.into()); let r = s.execute("file.revealInFinder", json!({"dryRun": true})).unwrap(); - let (program, args) = crate::layer_menu_cmds::reveal_command(path); + let (program, args) = crate::layer_menu_cmds::reveal_command(path).unwrap(); assert_eq!(r["program"], json!(program)); assert_eq!(r["args"], json!(args)); // The tab menu names a background tab: the active document is not the one revealed. diff --git a/crates/engine/src/jobs.rs b/crates/engine/src/jobs.rs index de2afd1556..0e9569d71d 100644 --- a/crates/engine/src/jobs.rs +++ b/crates/engine/src/jobs.rs @@ -323,6 +323,9 @@ pub fn run( ) -> Result { let authorization = s.authorize.or_else(|| RENDER_AUTHORIZATION.with(std::cell::Cell::get)); let _authorization = AuthorizationScope::enter(authorization); + // The job's document: stored paths it follows are resolved against it on any thread. + let document = s.active().and_then(|d| d.path.as_deref()).map(std::path::PathBuf::from); + let _document = crate::doc_path::scope(document.as_deref()); if !s.jobs.spawn || cfg!(target_arch = "wasm32") { let t = work(&s.jobs.inline.clone().unwrap_or_default())?; return apply(s, t); @@ -337,8 +340,10 @@ pub fn run( }; let ctx = JobCtx::new(); let (tx, rx) = std::sync::mpsc::channel::>(); + let apply_document = document.clone(); let erased: Apply = Box::new(move |s, any| { let _authorization = AuthorizationScope::enter(authorization); + let _document = crate::doc_path::scope(apply_document.as_deref()); let t = any.downcast::().map_err(|_| EngineError::Other("internal error: job result type mismatch".into()))?; apply(s, *t) }); @@ -352,6 +357,7 @@ pub fn run( // 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 _document = crate::doc_path::scope(document.as_deref()); 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. @@ -694,6 +700,7 @@ impl Session { gate(id, ¶ms)?; } let _authorization = AuthorizationScope::enter(self.authorize); + let _document = crate::doc_path::scope(self.active().and_then(|d| d.path.as_deref()).map(std::path::Path::new)); // 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/layer_menu_cmds.rs b/crates/engine/src/layer_menu_cmds.rs index 366f9c8d39..8e033b244e 100644 --- a/crates/engine/src/layer_menu_cmds.rs +++ b/crates/engine/src/layer_menu_cmds.rs @@ -373,14 +373,17 @@ fn reveal_in_finder(s: &mut Session, p: &Value) -> Result { let SmartSource::Linked { path } = &sm.source else { return Err(other("embedded smart objects have no file to reveal")); }; - reveal(path, p.get("dryRun").and_then(Value::as_bool).unwrap_or(false)) + // The path is stored in the document: shown only when it would be followed. + let file = + crate::doc_path::resolve_in(path, d.path.as_deref().map(std::path::Path::new)).map_err(|why| crate::doc_path::refusal("the linked file", path, why))?; + reveal(&file.to_string_lossy(), p.get("dryRun").and_then(Value::as_bool).unwrap_or(false)) } /// Show `path` in the platform file manager ([`reveal_command`] + [`spawn`]); `dry_run` answers /// the program and arguments instead (tests). Shared by the smart-object command and the document /// tab's Reveal in Finder (UI-217-6). pub(crate) fn reveal(path: &str, dry_run: bool) -> Result { - let (program, args) = reveal_command(path); + let (program, args) = reveal_command(path)?; if dry_run { return Ok(json!({"program": program, "args": args})); } @@ -389,14 +392,23 @@ pub(crate) fn reveal(path: &str, dry_run: bool) -> Result { } /// The file-manager invocation that selects `path` on this platform. -pub fn reveal_command(path: &str) -> (&'static str, Vec) { - if cfg!(target_os = "macos") { - ("open", vec!["-R".into(), path.into()]) - } else if cfg!(target_os = "windows") { - ("explorer", vec![format!("/select,{path}")]) - } else { - let parent = std::path::Path::new(path).parent().map_or_else(|| ".".to_string(), |p| p.to_string_lossy().into_owned()); - ("xdg-open", vec![parent]) +pub fn reveal_command(path: &str) -> Result<(&'static str, Vec)> { + reveal_command_on(std::env::consts::OS, path) +} + +/// [`reveal_command`] on the platform `os` (a `std::env::consts::OS` value). +fn reveal_command_on(os: &str, path: &str) -> Result<(&'static str, Vec)> { + match os { + // `--` ends the options: a path starting with `-` is not read as one. + "macos" => Ok(("open", vec!["-R".into(), "--".into(), path.into()])), + // Explorer splits `/select,` arguments at commas and reads quotes itself, so such a path + // would select something else. + "windows" if path.contains([',', '"']) => Err(other(format!("{path} can't be shown in Explorer: its path contains a comma or a quotation mark"))), + "windows" => Ok(("explorer", vec![format!("/select,{path}")])), + _ => { + let parent = std::path::Path::new(path).parent().map_or_else(|| ".".to_string(), |p| p.to_string_lossy().into_owned()); + Ok(("xdg-open", vec![parent])) + } } } diff --git a/crates/engine/src/layer_menu_cmds/tests.rs b/crates/engine/src/layer_menu_cmds/tests.rs index 46688500b1..72ace605cb 100644 --- a/crates/engine/src/layer_menu_cmds/tests.rs +++ b/crates/engine/src/layer_menu_cmds/tests.rs @@ -465,16 +465,13 @@ fn stack_mode_rejects_sources_without_visible_layers() { #[test] fn reveal_in_finder_dry_run_and_enabled() { + let link = std::env::temp_dir().join("pics").join("a.png").to_string_lossy().into_owned(); let mut s = session(8, "rgb"); assert!(!s.is_enabled("layer.smartObjects.revealInFinder")); s.edit("linked", |doc, active| { let mut l = Layer::new( "linked", - LayerContent::Smart(photocraft_doc::SmartObject::new( - SmartSource::Linked { path: "/tmp/pics/a.png".into() }, - photocraft_geom::Affine::IDENTITY, - None, - )), + LayerContent::Smart(photocraft_doc::SmartObject::new(SmartSource::Linked { path: link.clone() }, photocraft_geom::Affine::IDENTITY, None)), ); l.visible = true; *active = Some(doc.insert_above(*active, l)); @@ -483,11 +480,41 @@ fn reveal_in_finder_dry_run_and_enabled() { .unwrap(); assert!(s.is_enabled("layer.smartObjects.revealInFinder")); let r = s.execute("layer.smartObjects.revealInFinder", json!({"dryRun": true})).unwrap(); - let (prog, args) = reveal_command("/tmp/pics/a.png"); + let (prog, args) = reveal_command(&link).unwrap(); assert_eq!(r["program"], json!(prog)); assert_eq!(r["args"], json!(args)); } +#[test] +fn reveal_follows_linked_paths_like_the_link_and_passes_paths_as_paths() { + let mut s = session(8, "rgb"); + let folder = std::env::temp_dir().join("pics"); + s.active_mut().unwrap().path = Some(folder.join("doc.pcraft").to_string_lossy().into_owned()); + let mut link = |path: &str| { + s.edit("linked", |doc, active| { + let so = photocraft_doc::SmartObject::new(SmartSource::Linked { path: path.into() }, photocraft_geom::Affine::IDENTITY, None); + *active = Some(doc.insert_above(*active, Layer::new("linked", LayerContent::Smart(so)))); + Ok(()) + }) + .unwrap(); + s.execute("layer.smartObjects.revealInFinder", json!({"dryRun": true})).map_err(|e| e.to_string()) + }; + let err = link(r"\\192.0.2.1\share\x.png").unwrap_err(); + assert!(err.contains("is not opened: the path is on another computer"), "{err}"); + // A relative link is shown next to the document. + let r = link("a.png").unwrap(); + assert_eq!(r["args"], json!(reveal_command(&folder.join("a.png").to_string_lossy()).unwrap().1)); + // The document's own file is shown wherever it is, a network share included. + s.active_mut().unwrap().path = Some(r"\\192.0.2.1\share\doc.psd".into()); + s.execute("file.revealInFinder", json!({"dryRun": true})).unwrap(); + + assert_eq!(reveal_command_on("macos", "-a.png").unwrap(), ("open", vec!["-R".to_string(), "--".into(), "-a.png".into()])); + assert_eq!(reveal_command_on("windows", r"C:\pics\a.png").unwrap(), ("explorer", vec![r"/select,C:\pics\a.png".to_string()])); + for path in [r"C:\pics\a,b.png", r#"C:\pics\a".png"#] { + assert!(reveal_command_on("windows", path).is_err(), "{path}"); + } +} + #[test] fn mask_all_objects_masks_the_subject() { let mut s = session(8, "rgb"); diff --git a/crates/engine/src/lib.rs b/crates/engine/src/lib.rs index b8d6167c3d..c46db72b85 100644 --- a/crates/engine/src/lib.rs +++ b/crates/engine/src/lib.rs @@ -34,6 +34,7 @@ mod crop_fill; pub mod cutout_cmds; pub mod display_color; pub mod distort_cmds; +pub mod doc_path; pub mod document_preset_cmds; pub mod edit_cmds; pub mod edit_menu_cmds; @@ -782,6 +783,7 @@ impl Session { // 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 _document = crate::doc_path::scope(self.active().and_then(|d| d.path.as_deref()).map(std::path::Path::new)); 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/link_watch.rs b/crates/engine/src/link_watch.rs index 5c235fb919..1885812ce0 100644 --- a/crates/engine/src/link_watch.rs +++ b/crates/engine/src/link_watch.rs @@ -32,15 +32,17 @@ pub type Stamp = (u64, u128); /// ids, stamp)]). pub type LinkScan = Arc, Option)>)>>>; +/// Stamps `file`, a linked path resolved by [`crate::doc_path`]; `None` (not looked at, like a +/// missing file) when it is not followed. #[cfg(not(target_arch = "wasm32"))] -fn stamp(path: &str) -> Option { - let meta = std::fs::metadata(path).ok()?; +fn stamp(file: Option<&std::path::Path>) -> Option { + let meta = std::fs::metadata(file?).ok()?; let modified = meta.modified().ok()?.duration_since(std::time::UNIX_EPOCH).ok()?; Some((meta.len(), modified.as_nanos())) } #[cfg(target_arch = "wasm32")] -fn stamp(_: &str) -> Option { +fn stamp(_: Option<&std::path::Path>) -> Option { None } @@ -61,11 +63,24 @@ pub fn linked_files(d: &Document) -> Vec<(String, Vec)> { /// the layers of the ones that changed. pub(crate) fn update_changed(s: &mut Session, _: &Value) -> Result { let st = s.active().ok_or(EngineError::NoDocument)?; - let (doc, files) = (st.doc.id.0, linked_files(&st.doc)); - let now = files.into_iter().map(|(p, ids)| (p.clone(), ids, stamp(&p))).collect(); + let (doc, files) = (st.doc.id.0, resolved_files(st)); + let now = files.into_iter().map(|(p, file, ids)| (p, ids, stamp(file.as_deref()))).collect(); s.apply_link_stamps(doc, now) } +/// [`linked_files`] of `st`'s document with the file each path resolves to, or `None` for a path +/// that is not followed ([`crate::doc_path`]). +fn resolved_files(st: &crate::DocState) -> Vec<(String, Option, Vec)> { + let document = st.path.as_deref().map(std::path::Path::new); + linked_files(&st.doc) + .into_iter() + .map(|(p, ids)| { + let file = crate::doc_path::resolve_in(&p, document).ok(); + (p, file, ids) + }) + .collect() +} + impl Session { /// Start stamping the active document's linked files on a worker thread, unless a look is /// still running (or the build has no files to look at: the web app). @@ -74,14 +89,15 @@ impl Session { return; } let Some(st) = self.active() else { return }; - let (doc, files) = (st.doc.id.0, linked_files(&st.doc)); - if files.is_empty() { + let (doc, files) = (st.doc.id.0, resolved_files(st)); + // Nothing to look at: no links, or none that is followed. + if files.iter().all(|(_, file, _)| file.is_none()) { return; } let slot: LinkScan = Arc::default(); let out = slot.clone(); let spawned = std::thread::Builder::new().name("link-watch".into()).spawn(move || { - let stamps = files.into_iter().map(|(p, ids)| (p.clone(), ids, stamp(&p))).collect(); + let stamps = files.into_iter().map(|(p, file, ids)| (p, ids, stamp(file.as_deref()))).collect(); *out.lock().unwrap_or_else(PoisonError::into_inner) = Some((doc, stamps)); }); if spawned.is_ok() { diff --git a/crates/engine/src/print_cmds.rs b/crates/engine/src/print_cmds.rs index 9a0af4a054..de16a94ac4 100644 --- a/crates/engine/src/print_cmds.rs +++ b/crates/engine/src/print_cmds.rs @@ -602,8 +602,12 @@ fn print_one_copy(s: &mut Session, p: &Value) -> Result { fn package(s: &mut Session, p: &Value) -> Result { let cmd = "file.package"; let dir = p.get("dir").and_then(Value::as_str).filter(|v| !v.is_empty()).ok_or_else(|| bad(cmd, "missing \"dir\" (where to create the package folder)"))?; + // The copies are relinked by absolute path: a relative one would be resolved against the + // package document's folder, not the working directory `dir` is relative to. + let dir = std::path::absolute(dir).map_or_else(|_| dir.to_string(), |d| d.to_string_lossy().into_owned()); let d = s.active().ok_or(EngineError::NoDocument)?; let doc = (*d.doc).clone(); + let document = d.path.clone(); let name = stem(&d.path.clone().unwrap_or_else(|| doc.name.clone())); // The native format keeps external links; our PSD writer has no `lnkE` (external linked // file) support yet, so a PSD package would embed or flatten them. @@ -612,7 +616,7 @@ fn package(s: &mut Session, p: &Value) -> Result { Some(f) => return Err(bad(cmd, format!("unknown package format `{f}` (pcraft|psd|psb)"))), None => "pcraft".into(), }; - let folder = join(dir, &crate::file_cmds::sanitize(&name)); + let folder = join(&dir, &crate::file_cmds::sanitize(&name)); let links_dir = join(&folder, "Links"); // Copy every linked file once; relink the copy of the document to the copies. let mut copied: Vec<(String, String)> = Vec::new(); @@ -629,7 +633,12 @@ fn package(s: &mut Session, p: &Value) -> Result { fname = format!("{}-{}", copied.len(), fname); } let t = join(&links_dir, &fname); - match read_file(path) { + // A path that is not followed is packaged as missing, like a file that is gone. + let Ok(file) = crate::doc_path::resolve_in(path, document.as_deref().map(std::path::Path::new)) else { + missing.push(path.clone()); + continue; + }; + match read_file(&file.to_string_lossy()) { Ok(bytes) => { write_file(&t, &bytes)?; copied.push((path.clone(), t.clone())); diff --git a/crates/engine/src/smart_cmds.rs b/crates/engine/src/smart_cmds.rs index 6ffc89e67d..1f1cde7987 100644 --- a/crates/engine/src/smart_cmds.rs +++ b/crates/engine/src/smart_cmds.rs @@ -77,8 +77,13 @@ fn base_name(path: &str) -> String { path.rsplit(['/', '\\']).next().unwrap_or(path).to_string() } +/// A linked file's bytes; `None` when it is missing or its path is not followed +/// ([`crate::doc_path::resolve`]). fn read_file(path: &str) -> Option> { - if path.is_empty() { None } else { photocraft_format::read_file(std::path::Path::new(path)).ok() } + if path.is_empty() { + return None; + } + photocraft_format::read_file(&crate::doc_path::resolve(path).ok()?).ok() } /// The source file of a smart object: embedded bytes, the PSD's embedded linked-layer data (PSD @@ -492,10 +497,14 @@ fn refresh_or_fail(doc: &mut Document, id: LayerId) -> Result<()> { fn refresh_or_fail_in(doc: &mut Document, id: LayerId, ctx: &JobCtx) -> Result<()> { if refresh_in(doc, id, ctx)? { - Ok(()) - } else { - Err(other("the smart object's contents are unavailable (missing linked file?), so it can't be re-rendered")) + return Ok(()); } + if let SmartSource::Linked { path } = &smart(doc, id)?.source + && let Err(why @ crate::doc_path::NotFollowed::Refused(_)) = crate::doc_path::resolve(path) + { + return Err(crate::doc_path::refusal("the linked file", path, why)); + } + Err(other("the smart object's contents are unavailable (missing linked file?), so it can't be re-rendered")) } fn smart(doc: &Document, id: LayerId) -> Result<&SmartObject> { diff --git a/crates/io/Cargo.toml b/crates/io/Cargo.toml index 9bd4c559c5..eb8c0b00d7 100644 --- a/crates/io/Cargo.toml +++ b/crates/io/Cargo.toml @@ -31,6 +31,7 @@ photocraft-text = { workspace = true } photocraft-paint = { workspace = true } photocraft-vector = { workspace = true } photocraft-format = { path = "../format" } +craft-docpath = { workspace = true } thiserror = { workspace = true } serde_json = { workspace = true } blake3 = { version = "1.8", default-features = false, features = ["pure"] } diff --git a/crates/io/src/doc_path.rs b/crates/io/src/doc_path.rs new file mode 100644 index 0000000000..37e7daf214 --- /dev/null +++ b/crates/io/src/doc_path.rs @@ -0,0 +1,59 @@ +//! Files named by paths stored in a document: linked smart objects, the Displace map and Color +//! Lookup file of a smart filter, data-set images, video footage. +//! +//! Such a path is followed only through [`resolve`]: a relative path is taken from the document's +//! folder, and only local files, or files on the network share the document itself is on, are +//! opened (see `craft_docpath`). A refused path is treated like a missing file. + +use std::fmt; +use std::path::{Path, PathBuf}; + +pub use craft_docpath::Refused; + +/// Why a stored path is not followed. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum NotFollowed { + /// The path is not a local file, or is not on the document's own share. + Refused(Refused), + /// The path is relative and the document has no file to take it from. + NoDocumentFolder, +} + +impl fmt::Display for NotFollowed { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + match self { + Self::Refused(r) => r.fmt(f), + Self::NoDocumentFolder => f.write_str("the path is relative and the document has not been saved"), + } + } +} + +impl std::error::Error for NotFollowed {} + +/// The file to open for `stored`, a path or `file:` URL stored in a document, or why it is not +/// followed. `document` is the path of the document it is stored in, if the document has a file; +/// a relative `document` is taken from the working directory, where it was opened from. +pub fn resolve(stored: &str, document: Option<&Path>) -> Result { + let document = document.map(|d| if d.is_relative() { std::path::absolute(d).unwrap_or_else(|_| d.to_path_buf()) } else { d.to_path_buf() }); + let path = craft_docpath::resolve(stored, document.as_deref()).map_err(NotFollowed::Refused)?; + // A path that is still relative would be taken from the working directory, which the + // document does not choose. + if path.is_relative() { + return Err(NotFollowed::NoDocumentFolder); + } + Ok(path) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn relative_paths_are_taken_from_the_document_folder() { + let doc = std::env::temp_dir().join("art").join("poster.pcraft"); + assert_eq!(resolve("logo.png", Some(&doc)), Ok(std::env::temp_dir().join("art").join("logo.png"))); + assert_eq!(resolve("logo.png", None), Err(NotFollowed::NoDocumentFolder)); + assert_eq!(resolve(r"\\192.0.2.1\share\x.png", Some(&doc)), Err(NotFollowed::Refused(Refused::Network))); + assert_eq!(resolve("//192.0.2.1/share/x.png", None), Err(NotFollowed::Refused(Refused::Network))); + } +} diff --git a/crates/io/src/lib.rs b/crates/io/src/lib.rs index 24b8b3f948..e3c6365d9a 100644 --- a/crates/io/src/lib.rs +++ b/crates/io/src/lib.rs @@ -40,6 +40,7 @@ pub mod blocks; pub mod camera_raw_xmp; mod channel_map; pub mod comps_map; +pub mod doc_path; pub mod effects_map; pub mod exr_parts; mod flat; From 348499a5e01ce58a56380f9d28dbf1d2f956a635 Mon Sep 17 00:00:00 2001 From: aiongg Date: Fri, 16 Oct 2026 16:53:21 +0000 Subject: [PATCH 26/85] PSD export: embed linked files only when they are local or on the document's share Save As PSD/PSB, layered TIFF and Package as PSD read a linked smart object's file to embed it. The path is resolved with the same resolver as other linked-file reads, against the document's own file (the new ExportOptions::document_path / PsdExportOptions::document_path, set by the engine's saves and the app's Save As). A link that is not followed is written as pixels with a warning that says why, as for a missing file. Nested smart-object documents resolve against the same document. --- crates/engine/src/doc_path/tests.rs | 18 ++++++++++++++++++ crates/engine/src/file_cmds.rs | 2 ++ crates/io/src/lib.rs | 11 ++++++++++- crates/io/src/psd_export.rs | 19 +++++++++++++++---- crates/io/src/tiff_layers.rs | 9 +++++++-- crates/ui-egui/src/lib.rs | 6 ++++++ 6 files changed, 58 insertions(+), 7 deletions(-) diff --git a/crates/engine/src/doc_path/tests.rs b/crates/engine/src/doc_path/tests.rs index 6b8657911f..b28fe3fb4f 100644 --- a/crates/engine/src/doc_path/tests.rs +++ b/crates/engine/src/doc_path/tests.rs @@ -127,3 +127,21 @@ fn package_copies_only_followed_links() { assert_eq!(links.len(), 1); assert!(links[0].as_str().unwrap().ends_with("red.png"), "{links:?}"); } + +#[test] +fn psd_saves_embed_only_followed_links() { + let d = dir("psd"); + png(&d.join("red.png"), |_| [255, 0, 0]); + let (mut s, id) = document(&d, &d.join("red.png")); + relink(&mut s, id, "red.png"); + s.execute("file.placeLinked", json!({"path": d.join("red.png").to_string_lossy()})).unwrap(); + let other = s.active().unwrap().active_layer.unwrap(); + relink(&mut s, other, REMOTE); + let warnings = |r: serde_json::Value| r["warnings"].as_array().unwrap().iter().map(|w| w.as_str().unwrap().to_string()).collect::>(); + let saved = warnings(s.execute("file.saveACopy", json!({"path": d.join("copy.psd").to_string_lossy()})).unwrap()); + assert!(saved.iter().any(|w| w.contains("red.png was embedded")), "{saved:?}"); + assert!(saved.iter().any(|w| w.contains("is not opened: the path is on another computer")), "{saved:?}"); + // Package as PSD writes the copy of the document, whose remote link stays as it was. + let packaged = warnings(s.execute("file.package", json!({"dir": d.join("pkg").to_string_lossy(), "format": "psd"})).unwrap()); + assert!(packaged.iter().any(|w| w.contains("is not opened: the path is on another computer")), "{packaged:?}"); +} diff --git a/crates/engine/src/file_cmds.rs b/crates/engine/src/file_cmds.rs index d9eb4d10c0..07ae78904b 100644 --- a/crates/engine/src/file_cmds.rs +++ b/crates/engine/src/file_cmds.rs @@ -340,6 +340,8 @@ pub(crate) fn encode(doc: &Document, path: &str, save: impl Into) -> R tga_bits: save.tga_bits, image_previews: save.image_previews, psd_maximize_compatibility: save.psd_maximize_compatibility, + // Linked files a PSD embeds are resolved against the document being worked on. + document_path: crate::doc_path::current(), ..Default::default() }; // No quality chosen: write the file the way it was opened (a JPEG at its own quality, a WebP diff --git a/crates/io/src/lib.rs b/crates/io/src/lib.rs index e3c6365d9a..3543cd359c 100644 --- a/crates/io/src/lib.rs +++ b/crates/io/src/lib.rs @@ -188,6 +188,9 @@ pub struct ExportOptions { /// File Compatibility (see [`PsdExportOptions::maximize_compatibility`]). On by default, as /// Photoshop's Always. pub psd_maximize_compatibility: bool, + /// The path of the document's own file, which linked smart objects that PSD/PSB and layered + /// TIFF embed are resolved against ([`doc_path::resolve`]). + pub document_path: Option, } impl Default for ExportOptions { @@ -200,6 +203,7 @@ impl Default for ExportOptions { tga_bits: None, image_previews: true, psd_maximize_compatibility: true, + document_path: None, } } } @@ -412,7 +416,12 @@ fn export_stages(doc: &Document, name_or_ext: &str, opts: &ExportOptions, ctl: & return Ok(ExportResult { bytes: photocraft_format::save_to_bytes(doc.as_ref(), &previews)?, warnings: Vec::new() }); } if ext == "psd" || ext == "psb" { - let o = PsdExportOptions { force_psb: opts.force_psb || ext == "psb", maximize_compatibility: opts.psd_maximize_compatibility, ..Default::default() }; + let o = PsdExportOptions { + force_psb: opts.force_psb || ext == "psb", + maximize_compatibility: opts.psd_maximize_compatibility, + document_path: opts.document_path.clone(), + ..Default::default() + }; let (mut file, mut warnings) = with_span(ctl, 0.0, PSD_MODEL_DONE, |ctl| psd_export::document_to_psd_interruptible(doc, &o, ctl)); // A cancelled conversion stops early with an incomplete model: never write it. ctl.check().map_err(|_| IoError::Cancelled)?; diff --git a/crates/io/src/psd_export.rs b/crates/io/src/psd_export.rs index 8ba30d83c4..64a5e7073a 100644 --- a/crates/io/src/psd_export.rs +++ b/crates/io/src/psd_export.rs @@ -41,11 +41,14 @@ pub struct PsdExportOptions { /// readers that only show the merged image see white. A document without layer records always /// gets the real image, since its pixels live nowhere else. pub maximize_compatibility: bool, + /// The path of the document's own file: linked smart objects are resolved against it + /// ([`crate::doc_path::resolve`]) before they are read and embedded. + pub document_path: Option, } impl Default for PsdExportOptions { fn default() -> Self { - PsdExportOptions { force_psb: false, merged_matte: true, merged_compression: Compression::Rle, maximize_compatibility: true } + PsdExportOptions { force_psb: false, merged_matte: true, merged_compression: Compression::Rle, maximize_compatibility: true, document_path: None } } } @@ -53,6 +56,8 @@ struct Ex { fmt: PixelFormat, /// Document resolution (for generated type-layer data). dpi: f32, + /// The document's own file, which linked smart objects are resolved against. + document: Option, /// Character/paragraph styles written into every type layer's engine data. text_styles: photocraft_doc::TextStyles, mask_fmt: PixelFormat, @@ -929,7 +934,8 @@ impl Ex { SmartSource::Linked { path } => { #[cfg(not(target_arch = "wasm32"))] { - let bytes = std::fs::read(path).map_err(|e| format!("the linked file {path} can't be read: {e}"))?; + let file = crate::doc_path::resolve(path, self.document.as_deref()).map_err(|e| format!("the linked file {path} is not opened: {e}"))?; + let bytes = std::fs::read(file).map_err(|e| format!("the linked file {path} can't be read: {e}"))?; let name = path.rsplit(['/', '\\']).next().unwrap_or(path).to_string(); self.warnings.push(format!("the linked smart object {name} was embedded (PSD export keeps no external links)")); self.embed(sm.contents_id, &name, &bytes) @@ -998,8 +1004,12 @@ impl Ex { return Err(format!("smart objects nest more than {MAX_NESTING} deep")); } let doc = photocraft_format::load_from_bytes(bytes).map_err(|e| format!("its contents can't be read: {e}"))?; - let (file, warnings) = - document_to_psd_nested(&doc, &PsdExportOptions { force_psb: true, ..Default::default() }, self.smart.depth + 1, &Interrupt::NONE); + let (file, warnings) = document_to_psd_nested( + &doc, + &PsdExportOptions { force_psb: true, document_path: self.document.clone(), ..Default::default() }, + self.smart.depth + 1, + &Interrupt::NONE, + ); let data = file.to_bytes().map_err(|e| format!("its contents can't be written: {e}"))?; let stem = file_name.rsplit_once('.').map_or(file_name, |(a, _)| a); self.warnings.extend(warnings.into_iter().map(|w| format!("smart object {stem}: {w}"))); @@ -1519,6 +1529,7 @@ fn document_to_psd_nested(doc: &Document, opts: &PsdExportOptions, depth: u32, c let mut ex = Ex { fmt, dpi: doc.resolution_dpi, + document: opts.document_path.clone(), text_styles: doc.text_styles.clone(), text_index: Default::default(), mask_fmt: PixelFormat::new(ColorMode::Grayscale, sample, false), diff --git a/crates/io/src/tiff_layers.rs b/crates/io/src/tiff_layers.rs index 3cd8de5b4c..628517b6f7 100644 --- a/crates/io/src/tiff_layers.rs +++ b/crates/io/src/tiff_layers.rs @@ -202,8 +202,13 @@ pub(crate) fn export_layered(doc: &Document, opts: &ExportOptions, ctl: &photocr let big = doc.size.width > 30_000 || doc.size.height > 30_000; // The PSD exporter renders the composite we need for the TIFF itself: unmatted (the TIFF // keeps straight alpha) and uncompressed (its planes are interleaved below). - let psd_opts = - PsdExportOptions { force_psb: opts.force_psb || big, merged_matte: false, merged_compression: Compression::Raw, maximize_compatibility: true }; + let psd_opts = PsdExportOptions { + force_psb: opts.force_psb || big, + merged_matte: false, + merged_compression: Compression::Raw, + maximize_compatibility: true, + document_path: opts.document_path.clone(), + }; let (file, w) = crate::with_span(ctl, 0.0, TIFF_MODEL_DONE, |ctl| crate::psd_export::document_to_psd_interruptible(doc, &psd_opts, ctl)); // A cancelled conversion stops early with an incomplete model: never encode it. ctl.check().map_err(|_| IoError::Cancelled)?; diff --git a/crates/ui-egui/src/lib.rs b/crates/ui-egui/src/lib.rs index bde51ff27a..5004e8ccf3 100644 --- a/crates/ui-egui/src/lib.rs +++ b/crates/ui-egui/src/lib.rs @@ -281,6 +281,9 @@ pub struct ExportSettings { /// Embed EXIF, resolution and other non-XMP metadata when the format supports it. Export As /// turns this off for Metadata: None; Save As leaves it on. pub embed_metadata: bool, + /// The path of the document's own file, which linked smart objects that PSD/PSB embed are + /// resolved against ([`photocraft_io::ExportOptions::document_path`]). + pub document_path: Option, } impl Default for ExportSettings { @@ -296,6 +299,7 @@ impl Default for ExportSettings { psd_maximize_compatibility: true, embed_icc: true, embed_metadata: true, + document_path: None, } } } @@ -319,6 +323,7 @@ impl ExportSettings { opts.tga_bits = self.tga_bits; opts.image_previews = self.image_previews; opts.psd_maximize_compatibility = self.psd_maximize_compatibility; + opts.document_path = self.document_path.clone(); opts } } @@ -1549,6 +1554,7 @@ impl PhotocraftApp { settings.jpeg_quality = e.jpeg_quality.map(|q| q.clamp(1, 100)); settings.webp_lossless = e.webp_lossless.unwrap_or(settings.webp_lossless); settings.webp_quality = e.webp_quality.map(|q| q.min(100)); + settings.document_path = st.path.as_deref().map(std::path::PathBuf::from); } settings } From 0a328189aaef8e11e5f36f76dbb54985ee763e90 Mon Sep 17 00:00:00 2001 From: aiongg Date: Fri, 16 Oct 2026 16:53:28 +0000 Subject: [PATCH 27/85] Smart filters, data sets, video: re-read stored files only when local or on the document's share Paths these features keep in the document go through the same resolver as linked smart objects (relative to the document's folder; other computers, devices and URLs refused): - A Displace smart filter's `mapPath` and a Color Lookup smart filter's LUT `file`, re-read on every re-render: a path that is not followed leaves the pixels as they are, as for a missing map or LUT. - Apply Data Set's Pixel Replacement images. - Reload Frame of a video layer's footage. Apply Data Set and Reload Frame say why a path is not followed. --- crates/engine/src/adjust_cmds.rs | 12 +++++ crates/engine/src/doc_path/tests.rs | 80 +++++++++++++++++++++++++++++ crates/engine/src/filters_ext.rs | 6 ++- crates/engine/src/variables_cmds.rs | 4 +- crates/engine/src/video_cmds.rs | 4 +- 5 files changed, 102 insertions(+), 4 deletions(-) diff --git a/crates/engine/src/adjust_cmds.rs b/crates/engine/src/adjust_cmds.rs index c1393bb94c..b9bcd437aa 100644 --- a/crates/engine/src/adjust_cmds.rs +++ b/crates/engine/src/adjust_cmds.rs @@ -296,6 +296,18 @@ fn has_doc(s: &Session) -> std::result::Result<(), String> { /// Image › Adjustments run as a smart filter (`image.adjustments.` recorded on a smart /// object): the adjustment applied to a copy of `surf`. `None` for kinds or params it can't run. pub(crate) fn adjust_as_filter(kind: &str, p: &Value, surf: &Surface) -> Option { + // A Color Lookup filter re-reads its LUT `file`, a path stored in the document: followed only + // as `doc_path` allows, else the filter can't run (like a missing file). + let resolved; + let p = match p.get("file").and_then(Value::as_str).filter(|f| kind == "colorLookup" && !f.is_empty()) { + Some(file) => { + let mut q = p.clone(); + q["file"] = json!(crate::doc_path::resolve(file).ok()?.to_str()?); + resolved = q; + &resolved + } + None => p, + }; let mut out = surf.clone(); if kind == "shadowsHighlights" { let sh = shadows_highlights_params(p); diff --git a/crates/engine/src/doc_path/tests.rs b/crates/engine/src/doc_path/tests.rs index b28fe3fb4f..393e85a062 100644 --- a/crates/engine/src/doc_path/tests.rs +++ b/crates/engine/src/doc_path/tests.rs @@ -145,3 +145,83 @@ fn psd_saves_embed_only_followed_links() { let packaged = warnings(s.execute("file.package", json!({"dir": d.join("pkg").to_string_lossy(), "format": "psd"})).unwrap()); assert!(packaged.iter().any(|w| w.contains("is not opened: the path is on another computer")), "{packaged:?}"); } + +/// The pixels of the active document after its smart object `id` re-renders with its first smart +/// filter's params set to `params`. +fn replayed(s: &mut Session, id: LayerId, params: serde_json::Value) -> Vec<[f32; 4]> { + s.edit("Params", |doc, _| { + if let Some(LayerContent::Smart(sm)) = doc.layer_mut(id).map(|l| &mut l.content) { + sm.smart_filters[0].params = params; + } + crate::smart_cmds::refresh(doc, id).map(|_| ()) + }) + .unwrap(); + let doc = &s.active().unwrap().doc; + photocraft_compose::render(doc, doc.bounds()).px +} + +#[test] +fn smart_filters_reread_only_followed_files() { + let d = dir("filters"); + png(&d.join("ramp.png"), |x| [(x * 8) as u8, 0, 0]); + png(&d.join("map.png"), |_| [255, 255, 255]); + // An inverting look: corner (r, g, b) of the cube maps to (1-r, 1-g, 1-b), red fastest. + let corners = (0..8).map(|i| format!("{} {} {}\n", 1 - (i & 1), 1 - ((i >> 1) & 1), 1 - ((i >> 2) & 1))).collect::(); + std::fs::write(d.join("invert.cube"), format!("LUT_3D_SIZE 2\n{corners}")).unwrap(); + let path = |name: &str| d.join(name).to_string_lossy().into_owned(); + + // Displace re-reads its `mapPath`. + let (mut s, id) = document(&d, &d.join("ramp.png")); + s.execute("filter.distort.displace", json!({"layer": id.0, "mapPath": path("map.png"), "horizontal": 50, "vertical": 0})).unwrap(); + let displaced = replayed(&mut s, id, json!({"mapPath": path("map.png"), "horizontal": 50, "vertical": 0})); + let unfiltered = replayed(&mut s, id, json!({"mapPath": REMOTE, "horizontal": 50, "vertical": 0})); + assert_ne!(displaced, unfiltered, "the map moves the pixels"); + assert_eq!(replayed(&mut s, id, json!({"mapPath": "map.png", "horizontal": 50, "vertical": 0})), displaced, "a relative map next to the document"); + #[cfg(unix)] + assert_eq!(replayed(&mut s, id, json!({"mapPath": network_alias(&d.join("map.png")), "horizontal": 50, "vertical": 0})), unfiltered); + + // Color Lookup re-reads its LUT `file`. + let (mut s, id) = document(&d, &d.join("ramp.png")); + s.execute("image.adjustments.colorLookup", json!({"layer": id.0, "lut": "none"})).unwrap(); + let inverted = replayed(&mut s, id, json!({"file": path("invert.cube")})); + let unfiltered = replayed(&mut s, id, json!({"file": REMOTE})); + assert_ne!(inverted, unfiltered, "the look changes the pixels"); + assert_eq!(replayed(&mut s, id, json!({"file": "invert.cube"})), inverted, "a relative look next to the document"); + #[cfg(unix)] + assert_eq!(replayed(&mut s, id, json!({"file": network_alias(&d.join("invert.cube"))})), unfiltered); +} + +#[test] +fn data_sets_and_footage_read_only_followed_files() { + let d = dir("datasets"); + png(&d.join("blue.png"), |_| [0, 0, 255]); + let mut s = Session::new(); + s.execute("file.new", json!({"width": 32, "height": 32, "background": "#ff0000"})).unwrap(); + s.active_mut().unwrap().path = Some(d.join("doc.pcraft").to_string_lossy().into_owned()); + let layer = s.active().unwrap().active_layer.unwrap(); + s.execute("image.variables.define", json!({"defs": [{"name": "img", "layer": layer.0, "type": "pixelReplacement", "method": "conform"}]})).unwrap(); + let set = |name: &str, path: &str| json!({"name": name, "values": [{"variable": "img", "kind": "pixels", "value": path}]}); + s.execute("image.variables.dataSets", json!({"dataSets": [set("remote", REMOTE), set("relative", "blue.png")]})).unwrap(); + let err = s.execute("image.applyDataSet", json!({"name": "remote"})).unwrap_err().to_string(); + assert!(err.contains("is not opened: the path is on another computer"), "{err}"); + assert_eq!(centre(&s), RED); + s.execute("image.applyDataSet", json!({"name": "relative"})).unwrap(); + assert_eq!(centre(&s), BLUE); + + // Reload Frame re-reads the video layer's footage. + s.execute("layer.videoLayers.newVideoLayerFromFile", json!({"path": d.join("blue.png").to_string_lossy()})).unwrap(); + let footage = |s: &mut Session, path: &str| { + let id = s.active().unwrap().active_layer.unwrap(); + s.edit("Footage", |doc, _| { + if let Some(v) = doc.layer_mut(id).and_then(|l| l.video.as_mut()) { + v.source = photocraft_doc::VideoSource::File { path: path.into() }; + } + Ok(()) + }) + .unwrap(); + s.execute("layer.videoLayers.reloadFrame", json!({})).map_err(|e| e.to_string()) + }; + let err = footage(&mut s, REMOTE).unwrap_err(); + assert!(err.contains("is not opened: the path is on another computer"), "{err}"); + footage(&mut s, "blue.png").unwrap(); +} diff --git a/crates/engine/src/filters_ext.rs b/crates/engine/src/filters_ext.rs index dae5e9b98b..e4f6523a19 100644 --- a/crates/engine/src/filters_ext.rs +++ b/crates/engine/src/filters_ext.rs @@ -466,13 +466,15 @@ fn map_from_path(path: &str, layer: Option) -> Result> /// The session-free part of [`resolve`]: Displace's `mapPath`. Smart filters record params, not /// the map, so their re-render reads the map again here; without it the filter passed the pixels -/// through. `mapDocument` needs the session and is left unresolved. +/// through. The path is stored in the document, so it is followed only as +/// [`crate::doc_path::resolve`] allows. `mapDocument` needs the session and is left unresolved. pub(crate) fn resolve_map_path(fp: &mut FilterParams, p: &Value) -> Result<()> { if let FilterParams::Displace { map, .. } = fp && map.is_none() && let Some(path) = map_path(p) { - *map = map_from_path(path, p.get("mapLayer").and_then(Value::as_u64))?.map(Arc::new); + let file = crate::doc_path::resolve(path).map_err(|why| crate::doc_path::refusal("the displacement map", path, why))?; + *map = map_from_path(&file.to_string_lossy(), p.get("mapLayer").and_then(Value::as_u64))?.map(Arc::new); } Ok(()) } diff --git a/crates/engine/src/variables_cmds.rs b/crates/engine/src/variables_cmds.rs index f42b744844..6308269703 100644 --- a/crates/engine/src/variables_cmds.rs +++ b/crates/engine/src/variables_cmds.rs @@ -199,7 +199,9 @@ fn replace_pixels(doc: &mut Document, layer: LayerId, path: &str, method: PixelM return Ok(()); } let fmt = doc.pixel_format(); - let bytes = std::fs::read(path).map_err(|e| EngineError::Other(format!("read `{path}`: {e}")))?; + // The path is stored in the document's data sets. + let file = crate::doc_path::resolve(path).map_err(|why| crate::doc_path::refusal("the image", path, why))?; + let bytes = std::fs::read(&file).map_err(|e| EngineError::Other(format!("read `{path}`: {e}")))?; let src = crate::file_cmds::import(path, &bytes)?; let img = crate::file_cmds::flattened(&src, fmt); let (iw, ih) = (src.size.width.max(1) as f64, src.size.height.max(1) as f64); diff --git a/crates/engine/src/video_cmds.rs b/crates/engine/src/video_cmds.rs index 6fb9ab4574..820745fd38 100644 --- a/crates/engine/src/video_cmds.rs +++ b/crates/engine/src/video_cmds.rs @@ -365,7 +365,9 @@ fn reload_frame(s: &mut Session, _p: &Value) -> Result { let photocraft_doc::VideoSource::File { path } = src else { return Err(EngineError::BadParams { cmd: "layer.videoLayers.reloadFrame".into(), msg: "this layer has no file source to reload".into() }); }; - let frames = load_frames(&path, fmt)?.frames; + // The footage path is stored in the document. + let file = crate::doc_path::resolve(&path).map_err(|why| crate::doc_path::refusal("the footage", &path, why))?; + let frames = load_frames(&file.to_string_lossy(), fmt)?.frames; edit_video(s, "Reload Frame", move |v, _| { v.original_frames = Some(frames.clone()); v.frames = frames; From 54a4782849e9571b4d6f7bdd6744674d76c4857a Mon Sep 17 00:00:00 2001 From: Nicolas Falesy <112726756+nicolasfalesy@users.noreply.github.com> Date: Fri, 16 Oct 2026 18:26:19 -0400 Subject: [PATCH 28/85] =?UTF-8?q?perf(camera-raw):=20Shadows/Highlights=20?= =?UTF-8?q?1.9=C3=97=20faster,=20bit=20for=20bit=20the=20same=20(#5964)=20?= =?UTF-8?q?(#6247)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The local Laplacian filter behind Shadows and Highlights builds a whole pyramid per reference value (up to 96). Three costs dominated it: - blur_step called mirror() (an integer remainder) for every tap of every pixel; the source rows are now found once per row and columns mirror only within two pixels of an edge. - up() blurred a zero-stuffed plane, three samples in four zero; it now adds only the nonzero taps, in the same order. - the small pyramid levels were split into one-row rayon tasks, costing more in scheduling than the rows; planes under 32K samples run on one thread. The output is bit for bit the same: a new test checks blur_step and up against the plain versions on odd, even, tiny and parallel-sized planes. --- crates/algo/src/camera_raw/local_tone.rs | 140 ++++++++++++++++++++--- 1 file changed, 126 insertions(+), 14 deletions(-) diff --git a/crates/algo/src/camera_raw/local_tone.rs b/crates/algo/src/camera_raw/local_tone.rs index 7844f8d74b..e1305c5d49 100644 --- a/crates/algo/src/camera_raw/local_tone.rs +++ b/crates/algo/src/camera_raw/local_tone.rs @@ -79,23 +79,48 @@ fn mirror(i: isize, n: usize) -> usize { const TAPS: [f32; 5] = [1.0 / 16.0, 4.0 / 16.0, 6.0 / 16.0, 4.0 / 16.0, 1.0 / 16.0]; +/// Planes smaller than this run their rows on one thread: the filter builds a whole pyramid per +/// reference (up to 96), and splitting its small levels into one-row tasks cost more in rayon's +/// scheduling than the rows themselves (a fifth of a Shadows render on 36 MP, measured). +const PAR_MIN: usize = 1 << 15; + +/// [`par_rows`] for a plane, on one thread when it is small (see [`PAR_MIN`]). +fn rows(buf: &mut [f32], w: usize, f: impl Fn(usize, &mut [f32]) + Sync + Send) { + if buf.len() < PAR_MIN { + buf.chunks_mut(w.max(1)).enumerate().for_each(|(y, row)| f(y, row)); + } else { + par_rows(buf, w, 1, f); + } +} + /// The binomial 5-tap blur, sampled every `step` pixels from 0 (1 = full resolution). fn blur_step(p: &Plane, step: usize) -> Plane { let (w2, h2) = (p.w.div_ceil(step), p.h.div_ceil(step)); - // Columns first at the kept rows, then rows at the kept columns. + // Columns first at the kept rows, then rows at the kept columns. Each sum adds the same terms + // in the same order as a `mirror` per tap would, so the result is bit for bit the same; the + // source rows are found once per row and the columns mirrored only near the edges. let mut tmp = vec![0.0f32; p.w * h2]; - par_rows(&mut tmp, p.w, 1, |y2, row| { + rows(&mut tmp, p.w, |y2, row| { let y = (y2 * step) as isize; + let src: [&[f32]; 5] = std::array::from_fn(|k| { + let r = mirror(y + k as isize - 2, p.h); + &p.v[r * p.w..(r + 1) * p.w] + }); for (x, o) in row.iter_mut().enumerate() { - *o = (0..5).map(|k| TAPS[k] * p.v[mirror(y + k as isize - 2, p.h) * p.w + x]).sum(); + *o = (0..5).map(|k| TAPS[k] * src[k][x]).sum(); } }); let mut out = vec![0.0f32; w2 * h2]; - par_rows(&mut out, w2, 1, |y2, row| { + rows(&mut out, w2, |y2, row| { let src = &tmp[y2 * p.w..(y2 + 1) * p.w]; for (x2, o) in row.iter_mut().enumerate() { - let x = (x2 * step) as isize; - *o = (0..5).map(|k| TAPS[k] * src[mirror(x + k as isize - 2, p.w)]).sum(); + let x = x2 * step; + *o = if x >= 2 && x + 2 < p.w { + let s = &src[x - 2..x + 3]; + (0..5).map(|k| TAPS[k] * s[k]).sum() + } else { + (0..5).map(|k| TAPS[k] * src[mirror(x as isize + k as isize - 2, p.w)]).sum() + }; } }); Plane { w: w2, h: h2, v: out } @@ -107,14 +132,45 @@ fn down(p: &Plane) -> Plane { } /// `p` brought up to `w × h`: zeros between samples, then the blur times four. +/// +/// Three samples in four are zero, so their taps are skipped instead of blurring a zero-filled +/// plane: the nonzero terms are added in the same order (skipping `+0.0` terms changes at most the +/// sign of a zero, and the filter only adds and exponentiates these values). fn up(p: &Plane, w: usize, h: usize) -> Plane { - let mut z = Plane { w, h, v: vec![0.0; w * h] }; - for y in 0..p.h.min(h.div_ceil(2)) { - for x in 0..p.w.min(w.div_ceil(2)) { - z.v[2 * y * w + 2 * x] = p.v[y * p.w + x] * 4.0; - } + let (lh, lw) = (p.h.min(h.div_ceil(2)), p.w.min(w.div_ceil(2))); + // The source sample behind index `i` of a zero-stuffed axis of length `n` holding `lim` samples. + let src = |i: isize, n: usize, lim: usize| { + let m = mirror(i, n); + (m.is_multiple_of(2) && m / 2 < lim).then_some(m / 2) + }; + // Columns first, only at the even columns (the odd ones are all zero). + let mut tmp = vec![0.0f32; lw * h]; + if lw > 0 { + rows(&mut tmp, lw, |y, row| { + let taps: [Option<&[f32]>; 5] = std::array::from_fn(|k| src(y as isize + k as isize - 2, h, lh).map(|r| &p.v[r * p.w..r * p.w + lw])); + for (j, o) in row.iter_mut().enumerate() { + *o = (0..5).filter_map(|k| taps[k].map(|s| TAPS[k] * (s[j] * 4.0))).sum(); + } + }); } - blur_step(&z, 1) + let mut out = vec![0.0f32; w * h]; + rows(&mut out, w, |y, row| { + let t = &tmp[y * lw..(y + 1) * lw]; + for (x, o) in row.iter_mut().enumerate() { + *o = if x >= 2 && x + 2 < w { + // Away from the edges no tap mirrors. + (0..5) + .filter_map(|k| { + let i = x + k - 2; + (i.is_multiple_of(2) && i / 2 < lw).then(|| TAPS[k] * t[i / 2]) + }) + .sum() + } else { + (0..5).filter_map(|k| src(x as isize + k as isize - 2, w, lw).map(|j| TAPS[k] * t[j])).sum() + }; + } + }); + Plane { w, h, v: out } } /// Levels down to about 2 pixels on the short side. @@ -154,7 +210,7 @@ fn llf(u: &Plane, remap: impl Fn(f32, f32) -> f32 + Sync) -> Plane { for k in 0..refs { let r = lo + step * k as f32; let mut m = Plane { w: u.w, h: u.h, v: u.v.clone() }; - par_rows(&mut m.v, u.w, 1, |_, row| { + rows(&mut m.v, u.w, |_, row| { for v in row.iter_mut() { *v = remap(*v, r); } @@ -163,7 +219,7 @@ fn llf(u: &Plane, remap: impl Fn(f32, f32) -> f32 + Sync) -> Plane { for l in 0..n - 1 { let coarse = up(&gr[l + 1], gr[l].w, gr[l].h); let (gl, fine, lw) = (&g[l].v, &gr[l].v, g[l].w); - par_rows(&mut lap[l], lw, 1, |y, row| { + rows(&mut lap[l], lw, |y, row| { for (x, o) in row.iter_mut().enumerate() { let i = y * lw + x; let wgt = (1.0 - (gl[i].clamp(lo, hi) - r).abs() / step).max(0.0); @@ -337,6 +393,62 @@ mod tests { const SRGB: Encoding = Encoding(0.0); + /// The plain blur: a `mirror` per tap over the whole plane, the reference for [`blur_step`]. + fn blur_step_reference(p: &Plane, step: usize) -> Plane { + let (w2, h2) = (p.w.div_ceil(step), p.h.div_ceil(step)); + let mut tmp = vec![0.0f32; p.w * h2]; + for y2 in 0..h2 { + for x in 0..p.w { + tmp[y2 * p.w + x] = (0..5).map(|k| TAPS[k] * p.v[mirror((y2 * step) as isize + k as isize - 2, p.h) * p.w + x]).sum(); + } + } + let mut out = vec![0.0f32; w2 * h2]; + for y2 in 0..h2 { + for x2 in 0..w2 { + out[y2 * w2 + x2] = (0..5).map(|k| TAPS[k] * tmp[y2 * p.w + mirror((x2 * step) as isize + k as isize - 2, p.w)]).sum(); + } + } + Plane { w: w2, h: h2, v: out } + } + + /// The plain upsampling: a zero-stuffed plane blurred whole, the reference for [`up`]. + fn up_reference(p: &Plane, w: usize, h: usize) -> Plane { + let mut z = Plane { w, h, v: vec![0.0; w * h] }; + for y in 0..p.h.min(h.div_ceil(2)) { + for x in 0..p.w.min(w.div_ceil(2)) { + z.v[2 * y * w + 2 * x] = p.v[y * p.w + x] * 4.0; + } + } + blur_step_reference(&z, 1) + } + + fn noise_plane(w: usize, h: usize, seed: u32) -> Plane { + let mut s = seed.wrapping_mul(2_654_435_761).max(1); + let v = (0..w * h) + .map(|_| { + s ^= s << 13; + s ^= s >> 17; + s ^= s << 5; + (s % 20_000) as f32 / 1000.0 - 16.0 + }) + .collect(); + Plane { w, h, v } + } + + #[test] + fn the_fast_pyramid_matches_the_plain_one_exactly() { + // Odd, even, tiny and large-enough-to-run-in-parallel sizes, as the pyramid's levels are. + for (i, (w, h)) in [(1, 1), (1, 7), (2, 2), (3, 5), (5, 3), (8, 6), (37, 23), (64, 64), (301, 211)].into_iter().enumerate() { + let p = noise_plane(w, h, i as u32 + 1); + for step in [1, 2] { + assert_eq!(blur_step(&p, step).v, blur_step_reference(&p, step).v, "blur {w}×{h} step {step}"); + } + for (uw, uh) in [(w * 2, h * 2), (w * 2 - 1, h * 2 - 1), (w * 2, h * 2 - 1), (w.max(2) * 2 - 3, h * 2)] { + assert_eq!(up(&p, uw, uh).v, up_reference(&p, uw, uh).v, "up {w}×{h} → {uw}×{uh}"); + } + } + } + fn grey(w: usize, h: usize, f: impl Fn(usize, usize) -> f32) -> Vec<[f32; 4]> { (0..w * h).map(|i| f(i % w, i / w)).map(|v| [v, v, v, 1.0]).collect() } From 19227022ca5a3f908c4412a8acde5ef806825433 Mon Sep 17 00:00:00 2001 From: midasmatterhorn <248507632+midasmatterhorn@users.noreply.github.com> Date: Sat, 17 Oct 2026 01:36:58 +0300 Subject: [PATCH 29/85] Histogram: scale bar heights linearly to the tallest bin, not sorted.get(250) (#6638) --- crates/ui-egui/src/tone.rs | 42 ++++++++++++++++++++++++++++++++++---- 1 file changed, 38 insertions(+), 4 deletions(-) diff --git a/crates/ui-egui/src/tone.rs b/crates/ui-egui/src/tone.rs index ba17755bd9..aaf742096a 100644 --- a/crates/ui-egui/src/tone.rs +++ b/crates/ui-egui/src/tone.rs @@ -217,11 +217,17 @@ pub fn keep_after_commit(app: &mut PhotocraftApp, layer: LayerId, before: Option } } +/// Display scale for a 256-bin histogram: bar heights are `v / top` clamped to 1, linear +/// to the tallest bin like Photoshop. A percentile of the sorted bins degenerates on +/// sparse histograms (few occupied levels sort 0 into every low index), drawing tiny +/// counts as tall as the real peaks (#6574). +/// Extracted from [`draw_histogram`] so the scaling is unit-testable. +pub fn histogram_scale_top(h: &[u32; 256]) -> f32 { + h.iter().copied().max().unwrap_or(1).max(1) as f32 * 1.1 +} + pub fn draw_histogram(p: &egui::Painter, r: Rect, h: &[u32; 256], color: Color32) { - // Scale to a high percentile so a single spike (e.g. pure white) doesn't flatten the rest. - let mut sorted: Vec = h.to_vec(); - sorted.sort_unstable(); - let top = sorted.get(250).copied().unwrap_or(1).max(1) as f32 * 1.1; + let top = histogram_scale_top(h); let mut pts = vec![egui::pos2(r.left(), r.bottom())]; for (i, v) in h.iter().enumerate() { let x = r.left() + (i as f32 + 0.5) / 256.0 * r.width(); @@ -452,6 +458,34 @@ mod tests { ); assert_ne!(tag(HistSource::Channel { layer, surface: GraySurface::LayerMask }, ToneSpace::Rgb), tag(HistSource::Layer(layer), ToneSpace::Rgb)); } + + /// #6574: bar heights scale linearly to the tallest bin. A 90/10 black/white + /// histogram draws the small bar at a ninth of the big one, not full height. + #[test] + fn histogram_scale_is_proportional_for_two_level_images() { + let mut h = [0u32; 256]; + h[0] = 100_000; + h[255] = 900_000; + let top = histogram_scale_top(&h); + let small = (h[0] as f32 / top).min(1.0); + let big = (h[255] as f32 / top).min(1.0); + assert!((small / big - 1.0 / 9.0).abs() < 0.02, "small/big = {}, top = {top}", small / big); + } + + /// #6574: a sparse histogram (few occupied levels) must not scale to a bin + /// index that is 0: the old `sorted.get(250)` degenerated to `top = 1.1`, + /// drawing a few hundred samples as tall as hundreds of thousands. + #[test] + fn histogram_scale_does_not_degenerate_on_sparse_histograms() { + let mut h = [0u32; 256]; + h[0] = 500_000; + h[128] = 300; + h[255] = 500_000; + let top = histogram_scale_top(&h); + assert!(top > h[128] as f32, "scale {top} must exceed the tiny middle bin"); + let tiny = (h[128] as f32 / top).min(1.0); + assert!(tiny < 0.05, "tiny bin draws near-flat, got {tiny}"); + } } #[cfg(test)] From 3c75db7a80eeb7c8b21fb48cced99f044627895a Mon Sep 17 00:00:00 2001 From: Korg1ch <57859048+Korg1ch@users.noreply.github.com> Date: Sat, 17 Oct 2026 01:37:17 +0300 Subject: [PATCH 30/85] fix(linux): avoid duplicate AppImage desktop launcher when managed by AM / AppMan (#6392) (#6394) * fix(linux): avoid duplicate AppImage desktop launcher when managed by AM / AppMan (#6392) * test(linux): cover AM / AppMan launcher detection in AppRun smoke test (#6392) --- packaging/linux/AppRun | 10 ++++++++++ packaging/linux/apprun-test.sh | 23 +++++++++++++++++++++++ 2 files changed, 33 insertions(+) diff --git a/packaging/linux/AppRun b/packaging/linux/AppRun index 43d0eeef7d..b0c58d9d31 100755 --- a/packaging/linux/AppRun +++ b/packaging/linux/AppRun @@ -53,6 +53,16 @@ integrate() { break fi done + # AM / AppMan (#6392) install their own launcher, whose Exec runs a command on + # PATH rather than this AppImage, so the check above misses it. AM's author + # names the only two places it writes one: system-wide for AM, per user for + # AppMan and rootless installs. + for f in /usr/local/share/applications/photocraft-AM.desktop "$apps/photocraft-AM.desktop"; do + if [ -f "$f" ]; then + external=1 + break + fi + done if [ "$external" = 1 ]; then if [ -f "$apps/$DESK_ID.desktop" ]; then rm -f "$apps/$DESK_ID.desktop" 2>/dev/null || true diff --git a/packaging/linux/apprun-test.sh b/packaging/linux/apprun-test.sh index 8c9e33e6bd..18f920560b 100755 --- a/packaging/linux/apprun-test.sh +++ b/packaging/linux/apprun-test.sh @@ -129,4 +129,27 @@ out="$(APPIMAGE="$odd" "$APPDIR/AppRun" x)" || fail "launch with an odd path exi [ "$out" = "stub: x" ] || fail "odd path: $out" [ ! -e "$XDG_DATA_HOME" ] || fail "integrated an AppImage path that needs escaping" +# 12. AM / AppMan (#6392): a photocraft-AM.desktop launcher in the user's applications +# dir owns the menu entry. AppRun removes its own entry left from an earlier run, +# writes no second one and still installs the icons. +rm -rf "$XDG_DATA_HOME" +"$APPDIR/AppRun" >/dev/null || fail "launch before AM install exited non-zero" +[ -f "$DESKTOP" ] || fail "no desktop entry before AM install" +am_desktop="$XDG_DATA_HOME/applications/photocraft-AM.desktop" +printf '[Desktop Entry] +Type=Application +Name=PhotoCraft +Exec=photocraft %%F +Icon=%s +' "$APP_ID" >"$am_desktop" +"$APPDIR/AppRun" >/dev/null || fail "launch with AM launcher exited non-zero" +[ ! -e "$DESKTOP" ] || fail "duplicate desktop entry kept alongside AM launcher (#6392)" +[ -f "$am_desktop" ] || fail "AM launcher was removed" +[ -f "$XDG_DATA_HOME/icons/hicolor/256x256/apps/$APP_ID.png" ] || fail "icons missing with AM launcher" + +# 13. Once AM removes its launcher, AppRun writes its own entry again. +rm -f "$am_desktop" +"$APPDIR/AppRun" >/dev/null || fail "launch after AM removal exited non-zero" +[ -f "$DESKTOP" ] || fail "no desktop entry after the AM launcher was removed" + echo "AppRun desktop integration ok" From ad6463b50f6844001c7927a0d36fbf6acfae783b Mon Sep 17 00:00:00 2001 From: Diego Chagas Date: Fri, 16 Oct 2026 19:37:45 -0300 Subject: [PATCH 31/85] Paragraph text: a word wider than the box breaks at the box edge (#6694) (#6696) parley's default overflow wrap only breaks at UAX #14 opportunities, so a word longer than the paragraph box ran out past its edge and resizing the box changed nothing. Paragraph text now sets OverflowWrap::BreakWord, so such a word wraps at the box edge as Photoshop does; a space or hyphen still wins over a mid-word break, a box that fits the word lays out as before, and point text is untouched. --- crates/text/src/layout.rs | 6 ++++++ crates/text/src/tests.rs | 34 ++++++++++++++++++++++++++++++++++ 2 files changed, 40 insertions(+) diff --git a/crates/text/src/layout.rs b/crates/text/src/layout.rs index e2bd223b3b..19d7b8c72d 100644 --- a/crates/text/src/layout.rs +++ b/crates/text/src/layout.rs @@ -586,6 +586,12 @@ impl Layouter { for p in style_props(&out.styles[si0], k, &fallback, si0 as u32, small_caps[si0]) { b.push_default(p); } + // Paragraph text breaks a word wider than the box at the box edge, as Photoshop + // does (#6694): parley's default only breaks at UAX #14 opportunities and lets the + // word run out of the box. Point text has no width to overflow. + if is_box { + b.push_default(StyleProperty::OverflowWrap(parley::style::OverflowWrap::BreakWord)); + } for (ri, st) in out.styles.iter().enumerate() { let rs = run_starts[ri]; let (a, z) = (rs.max(prange.start), (rs + runs[ri].len).min(content_end)); diff --git a/crates/text/src/tests.rs b/crates/text/src/tests.rs index 13de665ccd..7afb4d6cd1 100644 --- a/crates/text/src/tests.rs +++ b/crates/text/src/tests.rs @@ -2008,3 +2008,37 @@ fn hyphenless_postscript_names_split_trailing_styles() { assert_eq!((r.family.as_str(), r.weight, r.italic), (family, weight, italic), "{ps}"); } } + +/// A word wider than its paragraph box wraps at the box edge instead of running past it (#6694); +/// a normal break opportunity still wins, and a box that fits the word lays out exactly as before. +#[test] +fn a_word_wider_than_the_paragraph_box_breaks_at_the_box_edge() { + let mut e = TextEngine::new(); + let word = "BLOQUEADO?!"; + let alone = e.layout(&point(word, 12.0), 72.0); + assert_eq!(alone.lines.len(), 1, "{:?}", alone.lines); + let word_width = width(&alone); + let narrow = 30.0; + assert!(word_width > narrow * 2.0, "{word_width}"); + + let mut t = point(word, 12.0); + t.shape = TextShape::Box { x: 0.0, y: 0.0, width: narrow, height: 200.0 }; + let l = e.layout(&t, 72.0); + assert!(l.lines.len() >= 2, "{:?}", l.lines); + for line in &l.lines { + assert!(line.x1 <= narrow + 0.5, "{line:?}"); + } + let joined: String = l.lines.iter().map(|l| &word[l.range.clone()]).collect(); + assert_eq!(joined, word); + + t.shape = TextShape::Box { x: 0.0, y: 0.0, width: word_width + 20.0, height: 200.0 }; + let fits = e.layout(&t, 72.0); + assert_eq!(fits.lines.len(), 1, "{:?}", fits.lines); + assert!((width(&fits) - word_width).abs() < 0.01, "{} vs {word_width}", width(&fits)); + + let mut spaced = point("ab cd", 12.0); + spaced.shape = TextShape::Box { x: 0.0, y: 0.0, width: narrow, height: 200.0 }; + let l = e.layout(&spaced, 72.0); + assert_eq!(l.lines.len(), 2, "{:?}", l.lines); + assert_eq!(l.lines[1].range.start, 3, "{:?}", l.lines); +} From 97f50b471467611dbecec63ad0c9a2b6b304e6e1 Mon Sep 17 00:00:00 2001 From: Marco Arnulfo <103679419+marcoarnulfo@users.noreply.github.com> Date: Sat, 17 Oct 2026 00:38:11 +0200 Subject: [PATCH 32/85] Pen: the path in progress stays visible while panning with Space (fixes #6592) (#6625) --- crates/ui-egui/src/canvas.rs | 2 + crates/ui-egui/src/pen_draw_tests.rs | 56 ++++++++++++++++++++++++++++ 2 files changed, 58 insertions(+) diff --git a/crates/ui-egui/src/canvas.rs b/crates/ui-egui/src/canvas.rs index 57d3575111..acbf95063f 100644 --- a/crates/ui-egui/src/canvas.rs +++ b/crates/ui-egui/src/canvas.rs @@ -4091,6 +4091,8 @@ pub fn canvas_view(app: &mut PhotocraftApp, ui: &mut egui::Ui, idx: usize, rect: if primary && hand_pan && response.dragged() { crate::transform_tool::draw_content(app, &painter, &xf); crate::transform_tool::draw_overlay(app, &painter, &xf); + // The Pen's path in progress survives a Space pan, and stays on screen through it (#6592). + crate::vector_ui::draw_overlay(app, &painter, &xf, &doc); } app.tool_override = None; if let Some(b) = layer_edges_rect(app, idx) { diff --git a/crates/ui-egui/src/pen_draw_tests.rs b/crates/ui-egui/src/pen_draw_tests.rs index 155450eac6..57be940bd6 100644 --- a/crates/ui-egui/src/pen_draw_tests.rs +++ b/crates/ui-egui/src/pen_draw_tests.rs @@ -219,3 +219,59 @@ fn delete_with_the_pen_deletes_the_finished_work_path_not_the_pixels() { h.run_steps(2); assert!(work(&h), "another tool's Delete is Edit › Clear, not Delete Path"); } + +fn space(h: &mut Harness<'static, PhotocraftApp>, down: bool) { + h.event(egui::Event::Key { key: Key::Space, physical_key: None, pressed: down, repeat: false, modifiers: Modifiers::NONE }); + h.run_steps(1); +} + +/// Did the last frame paint the Pen's path in progress: an accent polyline starting at `at`, its +/// first anchor on screen (`vector_ui::draw_overlay`)? +fn pen_path_painted(h: &Harness<'static, PhotocraftApp>, at: Pos2) -> bool { + let accent = crate::theme::Tokens::get(&h.ctx).accent; + h.output().shapes.iter().any(|c| match &c.shape { + egui::Shape::Path(p) => { + p.stroke.width == 1.5 + && matches!(p.stroke.color, egui::epaint::ColorMode::Solid(col) if col == accent) + && p.points.len() >= 2 + && p.points[0].distance(at) < 1.0 + } + _ => false, + }) +} + +/// #6592: holding Space to pan with the Hand keeps the Pen's path in progress, and shows it, as in +/// Photoshop. +#[test] +fn space_pan_keeps_the_pen_path_in_progress_visible() { + let mut h = harness(); + click(&mut h, [20.0, 20.0]); + click(&mut h, [100.0, 20.0]); + assert_eq!(h.state().ui.pen.as_ref().map(|p| p.knots.len()), Some(2)); + let p = screen(&h, [150.0, 100.0]); + h.event(egui::Event::PointerMoved(p)); + h.run_steps(1); + assert!(pen_path_painted(&h, screen(&h, [20.0, 20.0])), "drawn before Space"); + space(&mut h, true); + assert!(pen_path_painted(&h, screen(&h, [20.0, 20.0])), "drawn with Space held, before the drag"); + let c0 = h.state().ui.views[0].center; + button(&mut h, p, true, Modifiers::NONE); + for i in 1..=5 { + // A pan frame paints the image and its overlays with the view it started with. + let first = screen(&h, [20.0, 20.0]); + h.event(egui::Event::PointerMoved(p + vec2(10.0 * i as f32, 0.0))); + h.run_steps(1); + let pen = h.state().ui.pen.as_ref().map(|p| p.knots.len()); + let painted = pen_path_painted(&h, first); + assert_eq!(pen, Some(2), "the path in progress is kept while panning (step {i})"); + assert!(painted, "the path in progress is drawn while panning (step {i})"); + } + button(&mut h, p + vec2(50.0, 0.0), false, Modifiers::NONE); + space(&mut h, false); + h.run_steps(1); + let c1 = h.state().ui.views[0].center; + assert!((c1[0] - (c0[0] - 50.0)).abs() < 0.5, "Space-drag panned: {c0:?} -> {c1:?}"); + assert_eq!(h.state().ui.tool, Tool::Pen); + assert_eq!(h.state().ui.pen.as_ref().map(|p| p.knots.len()), Some(2), "the path in progress survives the pan"); + assert!(pen_path_painted(&h, screen(&h, [20.0, 20.0])), "drawn after the pan"); +} From 6a7fc88c79d81a8d7c6c6c1ec8c06326fb43b3e8 Mon Sep 17 00:00:00 2001 From: Marco Arnulfo <103679419+marcoarnulfo@users.noreply.github.com> Date: Sat, 17 Oct 2026 00:38:35 +0200 Subject: [PATCH 33/85] Crop tool: custom ratios keep three decimals, 1.414 : 1 no longer becomes 1.4 : 1 (fixes #6487) (#6503) --- crates/ui-egui/src/crop_presets.rs | 2 +- crates/ui-egui/src/crop_size.rs | 19 ++++++++++++++- crates/ui-egui/src/crop_ui.rs | 3 ++- crates/ui-egui/src/panels.rs | 39 ++++++++++++++++++++++++++++-- 4 files changed, 58 insertions(+), 5 deletions(-) diff --git a/crates/ui-egui/src/crop_presets.rs b/crates/ui-egui/src/crop_presets.rs index e049fdfa8c..e30b0d1cfc 100644 --- a/crates/ui-egui/src/crop_presets.rs +++ b/crates/ui-egui/src/crop_presets.rs @@ -109,7 +109,7 @@ pub fn savable(o: &ToolOptions) -> Option<(Map, String)> { if !(a.is_finite() && b.is_finite() && a > 0.0 && b > 0.0) { return None; } - let name = format!("{} : {}", crate::widgets::fmt_num(a), crate::widgets::fmt_num(b)); + let name = format!("{} : {}", crop_size::fmt_ratio(a), crop_size::fmt_ratio(b)); let mut fields = Map::new(); fields.insert("crop_ratio".into(), json!(o.crop_ratio)); Some((fields, name)) diff --git a/crates/ui-egui/src/crop_size.rs b/crates/ui-egui/src/crop_size.rs index 28a6a0e12f..b30cdd8c4a 100644 --- a/crates/ui-egui/src/crop_size.rs +++ b/crates/ui-egui/src/crop_size.rs @@ -146,6 +146,14 @@ pub fn ratio(o: &ToolOptions, doc_dpi: f64) -> Option { Some(w / h).filter(|k| k.is_finite() && *k > 0.0) } +/// Decimals a custom ratio side keeps and shows: 1.414 : 1 (ISO A sizes) as in Photoshop (#6487). +pub const RATIO_DECIMALS: u32 = 3; + +/// A ratio side as stored in a `crop_ratio` key ("1.414", "16"). +pub fn fmt_ratio(v: f64) -> String { + crate::widgets::fmt_num_prec(v, RATIO_DECIMALS) +} + /// Editable W and H shown for a custom aspect ratio. An existing valid ratio is authoritative; /// when either side is zero, use the in-progress values retained in ToolOptions. pub fn custom_ratio_fields(o: &ToolOptions, doc_size: (f64, f64)) -> (f32, f32) { @@ -170,7 +178,7 @@ pub fn set_custom_ratio_fields(o: &mut ToolOptions, width: f32, height: f32) { o.crop_custom_ratio_width = width; o.crop_custom_ratio_height = height; if width.is_finite() && height.is_finite() && width > 0.0 && height > 0.0 { - o.crop_ratio = format!("{}:{}", crate::widgets::fmt_num(f64::from(width)), crate::widgets::fmt_num(f64::from(height))); + o.crop_ratio = format!("{}:{}", fmt_ratio(f64::from(width)), fmt_ratio(f64::from(height))); } else { o.crop_ratio.clear(); } @@ -347,6 +355,15 @@ mod tests { assert_eq!(width / height, 4.0 / 3.0); } + #[test] + fn custom_ratio_keeps_three_decimals() { + // #6487: 1.414 : 1 (ISO A sizes) stays 1.414, not 1.4, as in Photoshop. + let mut options = ToolOptions::default(); + set_custom_ratio_fields(&mut options, 1.414, 1.0); + assert_eq!(options.crop_ratio, "1.414:1"); + assert_eq!(custom_ratio_fields(&options, (0.0, 0.0)), (1.414, 1.0)); + } + #[test] fn selecting_free_ratio_clears_previous_custom_dimensions() { let mut options = ToolOptions { crop_ratio: "16:9".into(), ..ToolOptions::default() }; diff --git a/crates/ui-egui/src/crop_ui.rs b/crates/ui-egui/src/crop_ui.rs index 78f8d44b50..f1df63ff14 100644 --- a/crates/ui-egui/src/crop_ui.rs +++ b/crates/ui-egui/src/crop_ui.rs @@ -645,7 +645,7 @@ pub fn swapped(r: [f64; 4]) -> [f64; 4] { /// no ratio is set; "original" becomes the document's size turned on its side. pub fn swapped_ratio(key: &str, doc_w: f64, doc_h: f64) -> Option { let (w, h) = crate::chrome_ui::crop_ratio(key, doc_w, doc_h).filter(|(w, h)| w.is_finite() && h.is_finite() && *w > 0.0 && *h > 0.0)?; - Some(format!("{}:{}", crate::widgets::fmt_num(h), crate::widgets::fmt_num(w))) + Some(format!("{}:{}", crate::crop_size::fmt_ratio(h), crate::crop_size::fmt_ratio(w))) } /// X: swaps the pending frame between portrait and landscape about its centre, keeping its angle, @@ -1949,6 +1949,7 @@ mod tests { app.ui.tool_options.crop_ratio = String::new(); assert!(swap_orientation(&mut app)); assert_eq!(app.ui.tool_options.crop_ratio, "", "no ratio stays none"); + assert_eq!(swapped_ratio("1.414:1", 1.0, 1.0).as_deref(), Some("1:1.414"), "decimals survive the swap (#6487)"); assert_eq!(swapped_ratio("0:5", 1.0, 1.0), None); assert_eq!(swapped_ratio("nonsense", 1.0, 1.0), None); // No frame, or another tool: X is Switch Foreground and Background Colors. diff --git a/crates/ui-egui/src/panels.rs b/crates/ui-egui/src/panels.rs index 4793554150..7f2eadd084 100644 --- a/crates/ui-egui/src/panels.rs +++ b/crates/ui-egui/src/panels.rs @@ -1279,9 +1279,10 @@ pub fn options_bar(app: &mut PhotocraftApp, ui: &mut egui::Ui) { // Keep partial edits in ToolOptions so the first side survives a frame; // Original Ratio shows the document's numbers, as Photoshop does. let (mut rw, mut rh) = crate::crop_size::custom_ratio_fields(o, doc_size); - let cw = widgets::value_field(ui, &mut rw, 0.0..=99_999.0, "", 54.0).changed(); + let decimals = crate::crop_size::RATIO_DECIMALS; + let cw = widgets::value_field_prec(ui, &mut rw, 0.0..=99_999.0, "", 54.0, decimals).changed(); let swap = icons::button(ui, "arrow-left-right", 22.0, false, tl!("Swaps height and width")).clicked(); - let ch = widgets::value_field(ui, &mut rh, 0.0..=99_999.0, "", 54.0).changed(); + let ch = widgets::value_field_prec(ui, &mut rh, 0.0..=99_999.0, "", 54.0, decimals).changed(); if swap { std::mem::swap(&mut rw, &mut rh); } @@ -6217,6 +6218,40 @@ mod compact_visual_regressions { } } + #[test] + fn the_crop_ratio_fields_show_three_decimals() { + // #6487: a 1.414 : 1 ratio reads 1.414 in the bar, not 1.4. + let ctx = egui::Context::default(); + crate::theme::install_fonts(&ctx); + let Some(kind) = crate::theme::ThemeKind::ALL.into_iter().find(|k| { + crate::theme::apply(&ctx, *k); + Tokens::get(&ctx).pro + }) else { + return; + }; + crate::theme::apply(&ctx, kind); + let mut app = PhotocraftApp::new(photocraft_engine::Session::new(), Default::default()); + app.ui.tool = Tool::Crop; + app.ui.tool_options.crop_ratio = "1.414:1".into(); + let screen = Rect::from_min_size(egui::Pos2::ZERO, vec2(1600.0, 120.0)); + let mut out = ctx.run_ui(input_at_scale(screen, 1.0), |ui| options_bar(&mut app, ui)); + for _ in 0..3 { + out.textures_delta.clear(); + out = ctx.run_ui(input_at_scale(screen, 1.0), |ui| options_bar(&mut app, ui)); + } + out.textures_delta.clear(); + let texts: Vec<&str> = out + .shapes + .iter() + .filter_map(|s| match &s.shape { + egui::Shape::Text(t) => Some(t.galley.job.text.as_str()), + _ => None, + }) + .collect(); + assert!(texts.contains(&"1.414"), "{texts:?}"); + assert_eq!(app.ui.tool_options.crop_ratio, "1.414:1", "showing the ratio leaves it alone"); + } + #[test] fn the_narrow_options_bar_preference_shortens_the_bar() { assert_eq!(options_bar_height(true, false), 36.0); From f1c45b887ee9fedbcd00613c8fdb804f32eb75ec Mon Sep 17 00:00:00 2001 From: Jason Xiang <77497570+0x6Ason@users.noreply.github.com> Date: Fri, 16 Oct 2026 15:38:54 -0700 Subject: [PATCH 34/85] fix(codecs): respect PAM ENDHDR line boundaries (#5874) --- crates/codecs/src/codecs/pnm.rs | 17 ++++++- crates/codecs/tests/pnm_headers.rs | 82 ++++++++++++++++++++++++++++++ 2 files changed, 98 insertions(+), 1 deletion(-) create mode 100644 crates/codecs/tests/pnm_headers.rs diff --git a/crates/codecs/src/codecs/pnm.rs b/crates/codecs/src/codecs/pnm.rs index ca36db8109..c16cccfd02 100644 --- a/crates/codecs/src/codecs/pnm.rs +++ b/crates/codecs/src/codecs/pnm.rs @@ -66,6 +66,21 @@ impl<'a> Reader<'a> { } } + /// PAM ends its header with an LF-terminated line, unlike P4–P6's single separator. + fn pam_endhdr(&mut self) -> Result<(), CodecError> { + while let Some(&c) = self.b.get(self.pos) { + self.pos += 1; + if c == b'\n' { + // Stop at the first LF: whitespace and '#' after it are raster bytes. + return Ok(()); + } + if !c.is_ascii_whitespace() { + return Err(err("unexpected data after PAM ENDHDR")); + } + } + Err(err("unterminated PAM ENDHDR line")) + } + fn rest(&self) -> &'a [u8] { &self.b[self.pos.min(self.b.len())..] } @@ -269,7 +284,7 @@ fn decode_pam(r: &mut Reader<'_>, limits: &Limits) -> Result _ => return Err(err("unknown PAM header field")), } } - r.single_ws()?; + r.pam_endhdr()?; let (w, h, depth, maxval) = match (w, h, depth, maxval) { (Some(a), Some(b), Some(c), Some(d)) => (a, b, c, d), _ => return Err(err("incomplete PAM header")), diff --git a/crates/codecs/tests/pnm_headers.rs b/crates/codecs/tests/pnm_headers.rs new file mode 100644 index 0000000000..b6d9c076f5 --- /dev/null +++ b/crates/codecs/tests/pnm_headers.rs @@ -0,0 +1,82 @@ +use photocraft_codecs::{ChannelLayout, CodecError, Format, SampleType, decode, decode_as}; + +const PAM_GRAY: &[u8] = b"P7\nWIDTH 1\nHEIGHT 1\nDEPTH 1\nMAXVAL 255\nENDHDR"; +const ENDINGS: &[&[u8]] = &[b"\n", b" \n", b"\t \n", b"\r\n", b" \t\r\n"]; + +#[test] +fn pam_consumes_the_complete_endhdr_line() { + for ending in ENDINGS { + let mut bytes = PAM_GRAY.to_vec(); + bytes.extend_from_slice(ending); + bytes.push(0x80); + let img = decode(&bytes).unwrap(); + assert_eq!(img.dimensions(), (1, 1)); + assert_eq!(img.layout(), ChannelLayout::Gray); + assert_eq!(img.data(), &[0x80], "ENDHDR ending {ending:?}"); + } +} + +#[test] +fn pam_preserves_every_possible_first_raster_byte() { + // In particular, LF, CR, space, tab and '#' must not be skipped as header syntax. + for ending in ENDINGS { + for first in 0..=255u8 { + let mut bytes = PAM_GRAY.to_vec(); + bytes.extend_from_slice(ending); + bytes.push(first); + assert_eq!(decode(&bytes).unwrap().data(), &[first], "ENDHDR ending {ending:?}, raster byte {first}"); + } + } +} + +#[test] +fn pam_crlf_header_preserves_u16_samples_and_alpha() { + for ending in ENDINGS { + let mut bytes = b"P7\r\nWIDTH 1\r\nHEIGHT 1\r\nDEPTH 2\r\nMAXVAL 65535\r\nTUPLTYPE GRAYSCALE_ALPHA\r\nENDHDR".to_vec(); + bytes.extend_from_slice(ending); + bytes.extend_from_slice(b" \n#\r"); + let img = decode(&bytes).unwrap(); + assert_eq!(img.layout(), ChannelLayout::GrayA); + assert_eq!(img.sample_type(), SampleType::U16); + assert_eq!(img.to_u16_samples().unwrap(), vec![0x200a, 0x230d], "ENDHDR ending {ending:?}"); + } +} + +#[test] +fn pam_rejects_unterminated_or_invalid_endhdr_lines() { + for suffix in [b"".as_slice(), b" ", b"\r", b" \t\r", b" \x80", b" \x80\n\x7f", b"#comment\n\x7f"] { + let mut bytes = PAM_GRAY.to_vec(); + bytes.extend_from_slice(suffix); + assert!(matches!(decode_as(Format::Pnm, &bytes), Err(CodecError::Malformed { .. })), "ENDHDR suffix {suffix:?}"); + } +} + +#[test] +fn pam_truncation_never_uses_header_whitespace_as_pixels() { + for ending in ENDINGS { + let mut bytes = PAM_GRAY.to_vec(); + bytes.extend_from_slice(ending); + bytes.push(0x80); + for cut in 0..bytes.len() { + assert!(decode_as(Format::Pnm, &bytes[..cut]).is_err(), "ENDHDR ending {ending:?}, truncated at {cut}"); + } + } +} + +#[test] +fn binary_pnm_keeps_its_single_whitespace_separator() { + for separator in *b" \t\r\n" { + for first in *b" \t\r\n#" { + for (header, channels) in [(b"P5\n1 1\n255".as_slice(), 1), (b"P6\n1 1\n255", 3)] { + let mut bytes = header.to_vec(); + bytes.push(separator); + bytes.extend(std::iter::repeat_n(first, channels)); + assert_eq!(decode(&bytes).unwrap().data(), vec![first; channels]); + } + let mut bytes = b"P4\n8 1".to_vec(); + bytes.extend_from_slice(&[separator, first]); + let expected: Vec = (0..8).map(|bit| if first & (0x80 >> bit) == 0 { 255 } else { 0 }).collect(); + assert_eq!(decode(&bytes).unwrap().data(), expected); + } + } +} From 651647fdf68e0ca6cbfb3131187eeeed936382eb Mon Sep 17 00:00:00 2001 From: wwew <145259726+wwewtech@users.noreply.github.com> Date: Sat, 17 Oct 2026 02:56:48 +0400 Subject: [PATCH 35/85] docs(cli): write product name as PhotoCraft in USAGE and description (#5449) --- apps/photocraft-cli/Cargo.toml | 2 +- apps/photocraft-cli/src/lib.rs | 4 ++-- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/apps/photocraft-cli/Cargo.toml b/apps/photocraft-cli/Cargo.toml index a6d3b49389..f4848f379a 100644 --- a/apps/photocraft-cli/Cargo.toml +++ b/apps/photocraft-cli/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "photocraft-cli" -description = "Headless Photocraft: convert, inspect, run commands, batch, MCP server" +description = "Headless PhotoCraft: convert, inspect, run commands, batch, MCP server" version.workspace = true edition.workspace = true license.workspace = true diff --git a/apps/photocraft-cli/src/lib.rs b/apps/photocraft-cli/src/lib.rs index b93d4ccc8f..03001ab02d 100644 --- a/apps/photocraft-cli/src/lib.rs +++ b/apps/photocraft-cli/src/lib.rs @@ -1,4 +1,4 @@ -//! Headless Photocraft command line. `run` is the whole program, so it can be +//! Headless PhotoCraft command line. `run` is the whole program, so it can be //! tested in-process as well as through the binary. #![deny(clippy::unwrap_used, clippy::expect_used, clippy::panic, clippy::unimplemented, clippy::todo, clippy::unreachable)] @@ -12,7 +12,7 @@ use photocraft_io::ExportOptions; use serde_json::{Value, json}; pub const USAGE: &str = "\ -photocraft-cli: headless Photocraft +photocraft-cli: headless PhotoCraft USAGE: photocraft-cli convert [--format ] [--quality <1-100>] [--tiff-layers] [--tga-bits <24|32>] [--dpx-bits <8|10|12|16>] From f360cb0b4a9c31085d3dcbe7877b64c8de5fed62 Mon Sep 17 00:00:00 2001 From: PerchenkoAnton <101743951+PerchAnton@users.noreply.github.com> Date: Sat, 17 Oct 2026 01:57:33 +0300 Subject: [PATCH 36/85] fix(engine): give artboard exports unique indexed filenames (#6655) --- crates/engine/src/artboard_cmds.rs | 7 +- crates/engine/src/artboard_cmds/tests.rs | 4 +- crates/engine/tests/artboard_export_names.rs | 103 +++++++++++++++++++ 3 files changed, 109 insertions(+), 5 deletions(-) create mode 100644 crates/engine/tests/artboard_export_names.rs diff --git a/crates/engine/src/artboard_cmds.rs b/crates/engine/src/artboard_cmds.rs index 5cebe86aa9..c3e3e079f7 100644 --- a/crates/engine/src/artboard_cmds.rs +++ b/crates/engine/src/artboard_cmds.rs @@ -528,7 +528,7 @@ fn chosen_artboards(doc: &Document, p: &Value) -> Vec { } } -/// File › Export › Artboards to Files: `_.` per board. +/// File › Export › Artboards to Files: `__.` per board. fn artboards_to_files(s: &mut Session, p: &Value) -> Result { let cmd = "file.export.artboardsToFiles"; let dir = str_param(p, "dir", cmd).or_else(|_| str_param(p, "output", cmd))?.to_string(); @@ -538,8 +538,9 @@ fn artboards_to_files(s: &mut Session, p: &Value) -> Result { let prefix = p.get("prefix").and_then(Value::as_str).map(str::to_string).unwrap_or_else(|| stem(&d.doc.name)); let doc = d.doc.clone(); let mut files = Vec::new(); - for one in chosen_documents(&doc, p)? { - let name = if prefix.is_empty() { sanitize(&one.name) } else { format!("{}_{}", sanitize(&prefix), sanitize(&one.name)) }; + for (i, one) in chosen_documents(&doc, p)?.into_iter().enumerate() { + // Names may be identical, or collide after sanitizing or filesystem case folding. + let name = if prefix.is_empty() { format!("{i:04}_{}", sanitize(&one.name)) } else { format!("{}_{i:04}_{}", sanitize(&prefix), sanitize(&one.name)) }; let path = join(&dir, &format!("{name}.{format}")); save_doc(&one, &path, save)?; files.push(path); diff --git a/crates/engine/src/artboard_cmds/tests.rs b/crates/engine/src/artboard_cmds/tests.rs index 1faf4cb75d..c7695f228e 100644 --- a/crates/engine/src/artboard_cmds/tests.rs +++ b/crates/engine/src/artboard_cmds/tests.rs @@ -146,8 +146,8 @@ fn export_to_files_and_pdf() { let r = s.execute("file.export.artboardsToFiles", json!({"dir": d, "prefix": "site"})).unwrap(); let files: Vec = r["files"].as_array().unwrap().iter().map(|v| v.as_str().unwrap().to_string()).collect(); assert_eq!(files.len(), 2); - assert!(files[0].ends_with("site_Empty_One.png"), "{files:?}"); - assert!(files[1].ends_with("site_Red Board.png"), "{files:?}"); + assert!(files[0].ends_with("site_0000_Empty_One.png"), "{files:?}"); + assert!(files[1].ends_with("site_0001_Red Board.png"), "{files:?}"); let img = photocraft_io::import("x.png", &std::fs::read(&files[1]).unwrap()).unwrap().document; assert_eq!((img.size.width, img.size.height), (10, 10)); let img2 = photocraft_io::import("x.png", &std::fs::read(&files[0]).unwrap()).unwrap().document; diff --git a/crates/engine/tests/artboard_export_names.rs b/crates/engine/tests/artboard_export_names.rs new file mode 100644 index 0000000000..f9c46f3eb7 --- /dev/null +++ b/crates/engine/tests/artboard_export_names.rs @@ -0,0 +1,103 @@ +//! #6613: each artboard must have its own output even when names collide on disk. +#![cfg(not(target_arch = "wasm32"))] + +use std::collections::HashSet; +use std::path::Path; + +use photocraft_engine::Session; +use serde_json::json; + +struct Board { + id: u64, + width: u32, + color: [f32; 4], +} + +fn fixture(depth: u32, names: &[&str]) -> (Session, Vec) { + let mut s = Session::new(); + s.execute("file.new", json!({"name": "Board Export", "width": 96, "height": 32, "depth": depth})).unwrap(); + let mut boards = Vec::new(); + for (i, name) in names.iter().enumerate() { + let width = 3 + i as u32; + let color = if i % 2 == 0 { [1.0, 0.0, 0.0, 1.0] } else { [0.0, 0.0, 1.0, 1.0] }; + let result = s.execute("layer.new.artboard", json!({"name": name, "rect": [12 * i, 0, width, 2], "background": "custom", "color": color})).unwrap(); + boards.push(Board { id: result["layer"].as_u64().unwrap(), width, color }); + } + (s, boards) +} + +fn check_export(depth: u32, names: &[&str], prefix: Option<&str>, format: &str, selected: Option<&[usize]>) { + let (mut s, boards) = fixture(depth, names); + let dir = tempfile::tempdir().unwrap(); + let mut params = json!({"dir": dir.path(), "format": format}); + if let Some(prefix) = prefix { + params["prefix"] = json!(prefix); + } + if let Some(selected) = selected { + params["artboards"] = json!(selected.iter().map(|i| boards[*i].id).collect::>()); + } + let expected: Vec<_> = boards.iter().enumerate().rev().filter(|(i, _)| selected.is_none_or(|ids| ids.contains(i))).map(|(_, b)| b).collect(); + let before = photocraft_format::save_to_bytes(&s.active().unwrap().doc, &Default::default()).unwrap(); + let revision = s.active().unwrap().revision; + let history = s.active().unwrap().history.past_len(); + let result = s.execute("file.export.artboardsToFiles", params).unwrap(); + let files: Vec<_> = result["files"].as_array().unwrap().iter().map(|f| f.as_str().unwrap()).collect(); + assert_eq!(files.len(), expected.len()); + // Also require uniqueness on filesystems that compare names without letter case. + assert_eq!(files.iter().map(|f| f.to_lowercase()).collect::>().len(), files.len(), "export paths collide: {files:?}"); + assert_eq!(std::fs::read_dir(dir.path()).unwrap().count(), files.len(), "one physical output per returned path"); + for (file, board) in files.iter().zip(expected) { + assert_eq!(Path::new(file).parent(), Some(dir.path())); + let bytes = std::fs::read(file).unwrap(); + let doc = photocraft_io::import(file, &bytes).unwrap().document; + assert_eq!((doc.size.width, doc.size.height), (board.width, 2), "wrong artboard in {file}"); + let px = photocraft_compose::flatten(&doc).px[0]; + for (actual, expected) in px.iter().zip(board.color) { + assert!((actual - expected).abs() < 1e-3, "wrong pixels in {file}: {px:?}"); + } + } + let after = s.active().unwrap(); + assert_eq!(photocraft_format::save_to_bytes(&after.doc, &Default::default()).unwrap(), before); + assert_eq!(after.revision, revision, "export must not edit the source document"); + assert_eq!(after.history.past_len(), history); +} + +#[test] +fn duplicate_names_preserve_each_board_at_every_depth_and_prefix() { + for depth in [8, 16, 32] { + for prefix in [None, Some(""), Some("Launch/Run")] { + check_export(depth, &["Artboard 1", "Artboard 1"], prefix, "png", None); + } + } +} + +#[test] +fn sanitized_and_empty_names_preserve_each_tiff() { + check_export(16, &["Hero/1", "Hero:1", "Hero?1", ".", " ", "layer"], Some(""), "tiff", None); +} + +#[test] +fn case_only_and_index_like_names_remain_distinct() { + check_export(8, &["Logo", "logo", "Logo_0001", "0000_Logo", "Лого", "лого"], Some("site"), "png", None); +} + +#[test] +fn selected_duplicate_names_keep_their_psd_contents_in_stack_order() { + check_export(16, &["Same", "Skip", "Same"], Some("chosen"), "psd", Some(&[0, 2])); +} + +#[test] +fn distinct_names_keep_their_corresponding_contents() { + check_export(8, &["Red", "Blue"], None, "png", None); +} + +#[test] +fn invalid_export_params_fail_before_creating_files() { + let (mut s, _) = fixture(8, &["One", "Two"]); + let dir = tempfile::tempdir().unwrap(); + for params in [json!({"dir": 3}), json!({"dir": dir.path(), "artboards": []}), json!({"dir": dir.path(), "artboards": [u64::MAX]})] { + assert!(s.execute("file.export.artboardsToFiles", params).is_err()); + assert_eq!(std::fs::read_dir(dir.path()).unwrap().count(), 0); + } + assert!(Session::new().execute("file.export.artboardsToFiles", json!({"dir": dir.path()})).is_err()); +} From e33384c9bae25f9bc5c75970d1234ddf44e5ddd4 Mon Sep 17 00:00:00 2001 From: wwew <145259726+wwewtech@users.noreply.github.com> Date: Sat, 17 Oct 2026 02:58:50 +0400 Subject: [PATCH 37/85] docs: write product name as PhotoCraft in crate module docs (#5455) --- apps/photocraft-web/src/main.rs | 2 +- apps/photocraft/src/main.rs | 2 +- crates/cms/src/lib.rs | 2 +- crates/cms/src/lutfile.rs | 2 +- crates/cms/src/synth.rs | 2 +- crates/doc/src/lib.rs | 2 +- crates/geom/src/lib.rs | 2 +- crates/paint/src/brush.rs | 2 +- crates/text/src/fonts.rs | 2 +- crates/text/src/lib.rs | 2 +- crates/ui-egui/examples/snapshot.rs | 2 +- crates/ui-egui/src/lib.rs | 2 +- crates/ui-egui/src/widgets.rs | 2 +- 13 files changed, 13 insertions(+), 13 deletions(-) diff --git a/apps/photocraft-web/src/main.rs b/apps/photocraft-web/src/main.rs index 5ba75f5254..428143c880 100644 --- a/apps/photocraft-web/src/main.rs +++ b/apps/photocraft-web/src/main.rs @@ -1,4 +1,4 @@ -//! Photocraft in the browser. +//! PhotoCraft in the browser. //! //! Runs the same [`photocraft_ui_egui::PhotocraftApp`] as the desktop app through eframe's web //! runner (wgpu: WebGPU where available, WebGL2 otherwise). Build with `trunk build --release` diff --git a/apps/photocraft/src/main.rs b/apps/photocraft/src/main.rs index c0f93bd319..abd70eb418 100644 --- a/apps/photocraft/src/main.rs +++ b/apps/photocraft/src/main.rs @@ -1,4 +1,4 @@ -//! Photocraft desktop app. +//! PhotoCraft desktop app. //! //! Usage: `photocraft [--control ] [--control-token <64-hex> | //! --control-token-file ] [--automation-read-root ] diff --git a/crates/cms/src/lib.rs b/crates/cms/src/lib.rs index d6911ccd24..f93b00372b 100644 --- a/crates/cms/src/lib.rs +++ b/crates/cms/src/lib.rs @@ -1,4 +1,4 @@ -//! Photocraft colour management: a pure-Rust ICC v2/v4 colour management module. +//! PhotoCraft colour management: a pure-Rust ICC v2/v4 colour management module. //! //! * [`Profile`] parses ICC profiles (matrix/TRC RGB, gray TRC, `mft1`/`mft2`/`mAB `/`mBA ` //! LUT profiles incl. CMYK↔Lab, `curv`/`para` curves) and writes ICC v4 profiles. diff --git a/crates/cms/src/lutfile.rs b/crates/cms/src/lutfile.rs index 0a68ef8bce..47ff164fda 100644 --- a/crates/cms/src/lutfile.rs +++ b/crates/cms/src/lutfile.rs @@ -245,7 +245,7 @@ fn smooth(x: f32) -> f32 { x * x * (3.0 - 2.0 * x) } -/// Generated looks shipped with Photocraft (procedural, CC0): `(id, label)`. +/// Generated looks shipped with PhotoCraft (procedural, CC0): `(id, label)`. pub const BUILTIN: [(&str, &str); 8] = [ ("warm", "Warm Filter"), ("cool", "Cool Filter"), diff --git a/crates/cms/src/synth.rs b/crates/cms/src/synth.rs index 96a23dbfce..390e509dce 100644 --- a/crates/cms/src/synth.rs +++ b/crates/cms/src/synth.rs @@ -2,7 +2,7 @@ //! //! Adobe's CMYK profiles (U.S. Web Coated SWOP, Coated FOGRA39 …) are proprietary, and the //! freely downloadable characterisation-based profiles come with redistribution conditions, so -//! Photocraft ships a CMYK profile it generates itself from a documented printer model: +//! PhotoCraft ships a CMYK profile it generates itself from a documented printer model: //! //! * **Forward model** (CMYK → XYZ): Yule–Nielsen modified Neugebauer (n = 2) over the 16 //! ink overprints, with Demichel coverage weights and a parabolic tone value increase (dot diff --git a/crates/doc/src/lib.rs b/crates/doc/src/lib.rs index c4f7f523d1..a1214e7938 100644 --- a/crates/doc/src/lib.rs +++ b/crates/doc/src/lib.rs @@ -1,4 +1,4 @@ -//! The Photocraft document model: pure data, no rendering, no UI. +//! The PhotoCraft document model: pure data, no rendering, no UI. //! //! Layer order: inside every group (and the root), `children[0]` is the **bottom** layer, matching //! compositing order and the PSD file order. UIs display the list reversed. diff --git a/crates/geom/src/lib.rs b/crates/geom/src/lib.rs index 0e76736013..d95e4b847f 100644 --- a/crates/geom/src/lib.rs +++ b/crates/geom/src/lib.rs @@ -1,4 +1,4 @@ -//! Geometry primitives shared by every Photocraft crate. +//! Geometry primitives shared by every PhotoCraft crate. //! //! Document space is integer pixels with the origin at the canvas top-left. //! Layers may extend beyond the canvas (negative coordinates are valid). diff --git a/crates/paint/src/brush.rs b/crates/paint/src/brush.rs index d60412a58d..54e74e2e1f 100644 --- a/crates/paint/src/brush.rs +++ b/crates/paint/src/brush.rs @@ -678,7 +678,7 @@ impl BrushSettings { pub struct BrushPreset { pub name: String, pub brush: BrushSettings, - /// Shipped with Photocraft (built-ins can be deleted from a session but are regenerated on reset). + /// Shipped with PhotoCraft (built-ins can be deleted from a session but are regenerated on reset). #[serde(default)] pub builtin: bool, /// Preset group (folder) in the Brushes panel, e.g. "General" or an imported file's name. diff --git a/crates/text/src/fonts.rs b/crates/text/src/fonts.rs index 5ce71e59ac..756ce87455 100644 --- a/crates/text/src/fonts.rs +++ b/crates/text/src/fonts.rs @@ -35,7 +35,7 @@ pub static INTER_MEDIUM: LazyLock> = LazyLock::new(|| inflate_font(bundl pub static INTER_SEMIBOLD: LazyLock> = LazyLock::new(|| inflate_font(bundled_font!("Inter-SemiBold.ttf"))); pub static JETBRAINS_MONO_REGULAR: LazyLock> = LazyLock::new(|| inflate_font(bundled_font!("JetBrainsMono-Regular.ttf"))); -/// Fonts shipped with Photocraft (OFL; licences in `assets/fonts`). +/// Fonts shipped with PhotoCraft (OFL; licences in `assets/fonts`). pub static BUNDLED: [(&str, &LazyLock>); 4] = [ ("Inter-Regular.ttf", &INTER_REGULAR), ("Inter-Medium.ttf", &INTER_MEDIUM), diff --git a/crates/text/src/lib.rs b/crates/text/src/lib.rs index ad5e90ae35..af4de19f52 100644 --- a/crates/text/src/lib.rs +++ b/crates/text/src/lib.rs @@ -1,4 +1,4 @@ -//! Photocraft's type engine. +//! PhotoCraft's type engine. //! //! * [`fonts::FontDb`]: bundled fonts (Inter, JetBrains Mono; always available, including on the //! web), optional system fonts from platform directory scans plus Fontconfig's Linux file list, diff --git a/crates/ui-egui/examples/snapshot.rs b/crates/ui-egui/examples/snapshot.rs index 31e4c60301..0d227255db 100644 --- a/crates/ui-egui/examples/snapshot.rs +++ b/crates/ui-egui/examples/snapshot.rs @@ -1,4 +1,4 @@ -//! Render the full Photocraft UI offscreen (no window, no focus stealing) and save a PNG. +//! Render the full PhotoCraft UI offscreen (no window, no focus stealing) and save a PNG. //! //! ```sh //! cargo run --release -p photocraft-ui-egui --example snapshot -- \ diff --git a/crates/ui-egui/src/lib.rs b/crates/ui-egui/src/lib.rs index bde51ff27a..4e82be7107 100644 --- a/crates/ui-egui/src/lib.rs +++ b/crates/ui-egui/src/lib.rs @@ -1,4 +1,4 @@ -//! Photocraft's first UI shell, built on egui/eframe. +//! PhotoCraft's first UI shell, built on egui/eframe. //! //! This crate is deliberately thin. Every action goes through //! [`photocraft_engine::Session::execute`], and all UI state lives in [`state::UiState`] (plain diff --git a/crates/ui-egui/src/widgets.rs b/crates/ui-egui/src/widgets.rs index 2acbdf3c8b..7235790cbe 100644 --- a/crates/ui-egui/src/widgets.rs +++ b/crates/ui-egui/src/widgets.rs @@ -1,4 +1,4 @@ -//! Custom widgets for the Photocraft look: cards with pill tabs, thin sliders with round knobs, +//! Custom widgets for the PhotoCraft look: cards with pill tabs, thin sliders with round knobs, //! monospace value fields with dimmed units, toggle switches, primary/secondary buttons. use egui::{Align2, Color32, CornerRadius, Pos2, Rect, Response, Sense, Stroke, StrokeKind, Ui, Vec2, pos2, vec2}; From 93f6376c5eec66389878d7981d9855f4ed1fead6 Mon Sep 17 00:00:00 2001 From: wwew <145259726+wwewtech@users.noreply.github.com> Date: Sat, 17 Oct 2026 02:59:41 +0400 Subject: [PATCH 38/85] docs: write product name as PhotoCraft in package descriptions and comments (#5615) --- apps/photocraft-web/Cargo.toml | 2 +- apps/photocraft/Cargo.toml | 2 +- crates/automation/Cargo.toml | 2 +- crates/gpu/src/compose.wgsl | 2 +- crates/io/tests/text_corpus.rs | 2 +- packaging/macos/Info.plist.in | 2 +- packaging/macos/entitlements.plist | 2 +- 7 files changed, 7 insertions(+), 7 deletions(-) diff --git a/apps/photocraft-web/Cargo.toml b/apps/photocraft-web/Cargo.toml index a4ded64181..786165b532 100644 --- a/apps/photocraft-web/Cargo.toml +++ b/apps/photocraft-web/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "photocraft-web" -description = "Photocraft in the browser (eframe web runner, WebGPU with WebGL2 fallback)" +description = "PhotoCraft in the browser (eframe web runner, WebGPU with WebGL2 fallback)" version.workspace = true edition.workspace = true license.workspace = true diff --git a/apps/photocraft/Cargo.toml b/apps/photocraft/Cargo.toml index e5af663471..8b7c832bb2 100644 --- a/apps/photocraft/Cargo.toml +++ b/apps/photocraft/Cargo.toml @@ -4,7 +4,7 @@ version.workspace = true edition.workspace = true license.workspace = true default-run = "photocraft" -description = "Photocraft: an open-source, native image editor" +description = "PhotoCraft: an open-source, native image editor" build = "build.rs" [features] diff --git a/crates/automation/Cargo.toml b/crates/automation/Cargo.toml index 0738140a41..fb718e5668 100644 --- a/crates/automation/Cargo.toml +++ b/crates/automation/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "photocraft-automation" -description = "MCP server (rmcp) over the Photocraft engine, plus a bridge to a running GUI" +description = "MCP server (rmcp) over the PhotoCraft engine, plus a bridge to a running GUI" version.workspace = true edition.workspace = true license.workspace = true diff --git a/crates/gpu/src/compose.wgsl b/crates/gpu/src/compose.wgsl index 2b9c1bbdf4..6457b1192c 100644 --- a/crates/gpu/src/compose.wgsl +++ b/crates/gpu/src/compose.wgsl @@ -1,4 +1,4 @@ -// Photocraft GPU compositor kernels. +// PhotoCraft GPU compositor kernels. // // Every pass renders one chunk of the canvas into a straight-alpha RGBA32F accumulator, reading // its inputs with `textureLoad` at the same pixel. Effect-map kernels (`fs_m*`) render diff --git a/crates/io/tests/text_corpus.rs b/crates/io/tests/text_corpus.rs index a2efe1aec7..8597ac49d8 100644 --- a/crates/io/tests/text_corpus.rs +++ b/crates/io/tests/text_corpus.rs @@ -169,7 +169,7 @@ fn corpus_photoshop_txt2_survives_an_unedited_save() { println!("Txt2 kept byte-for-byte in {checked} Photoshop type files"); } -/// A text layer created in Photocraft (no PSD data) exports as an editable type layer and +/// A text layer created in PhotoCraft (no PSD data) exports as an editable type layer and /// imports back with the same model. #[test] fn created_text_layer_roundtrips_through_psd() { diff --git a/packaging/macos/Info.plist.in b/packaging/macos/Info.plist.in index d325b2b9d6..b3cde0ea1c 100644 --- a/packaging/macos/Info.plist.in +++ b/packaging/macos/Info.plist.in @@ -71,7 +71,7 @@ NSHumanReadableCopyright Copyright © the PhotoCraft authors. MIT OR Apache-2.0. - + UTExportedTypeDeclarations diff --git a/packaging/macos/entitlements.plist b/packaging/macos/entitlements.plist index 05e3194d84..c32269c1d3 100644 --- a/packaging/macos/entitlements.plist +++ b/packaging/macos/entitlements.plist @@ -1,6 +1,6 @@ - From 8fe2c74f58dcebfd5f6400e91a3b362f8c63cf00 Mon Sep 17 00:00:00 2001 From: xsvm <2479667226@qq.com> Date: Sat, 17 Oct 2026 07:00:43 +0800 Subject: [PATCH 39/85] perf(testkit): sample RSS from /proc/self/statm, not smaps, on Linux (#5399) memory-stats reads /proc/self/smaps by default on Linux: every sample formats and parses one block per mapping while holding the mmap lock. The benches' RssSampler polls it every 2 ms, so on a profile of bevel_bench --size 4000x3000 the rss-sampler thread was ~22% of all samples (smaps_account 14.7%, smaps_pte_entry 6.3%), contending with the measured threads. Enable the crate's always_use_statm feature: one short read of the kernel's RSS counters, the same number VmRSS reports. The measured code is unchanged; only the harness overhead goes. --- crates/testkit/Cargo.toml | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/crates/testkit/Cargo.toml b/crates/testkit/Cargo.toml index 0d69300d90..3e8bbed919 100644 --- a/crates/testkit/Cargo.toml +++ b/crates/testkit/Cargo.toml @@ -14,5 +14,7 @@ photocraft-raster = { workspace = true } serde_json = { workspace = true } [target.'cfg(not(target_arch = "wasm32"))'.dependencies] -# In-process resident memory for the benchmarks (`perf`; MIT OR Apache-2.0). -memory-stats = "1.2" +# In-process resident memory for the benchmarks (`perf`; MIT OR Apache-2.0). `always_use_statm`: +# on Linux read /proc/self/statm, not /proc/self/smaps, which walks every mapping under the mmap +# lock and made the 2 ms sampler thread a fifth of a large bench's CPU time. +memory-stats = { version = "1.2", features = ["always_use_statm"] } From 462adeadd26a83ec1eb16c6a59cde8ae83b0e6e3 Mon Sep 17 00:00:00 2001 From: Jason Xiang <77497570+0x6Ason@users.noreply.github.com> Date: Fri, 16 Oct 2026 16:01:10 -0700 Subject: [PATCH 40/85] test(io): cover mixed-region CMYK TIFF composites (#5588) --- crates/io/tests/cmyk_composite_export.rs | 95 +++++++++++++++++++++++- 1 file changed, 92 insertions(+), 3 deletions(-) diff --git a/crates/io/tests/cmyk_composite_export.rs b/crates/io/tests/cmyk_composite_export.rs index 9f537dabfd..31c0b6969f 100644 --- a/crates/io/tests/cmyk_composite_export.rs +++ b/crates/io/tests/cmyk_composite_export.rs @@ -1,8 +1,8 @@ use photocraft_cms::{Builtin, Intent, Profile, Transform}; use photocraft_codecs::{ChannelLayout, SampleType as CodecSample, decode}; -use photocraft_color::{ColorMode, SampleType}; -use photocraft_doc::{Document, Layer, LayerContent}; -use photocraft_geom::Size; +use photocraft_color::{ColorMode, PixelFormat, SampleType}; +use photocraft_doc::{Document, Layer, LayerContent, LayerMask}; +use photocraft_geom::{Rect, Size}; use photocraft_io::{ExportOptions, document_to_image, export}; use photocraft_raster::Surface; @@ -15,6 +15,32 @@ fn document(depth: SampleType, alpha: f32) -> Document { doc } +/// Three bands exercise opaque, translucent and empty pixels in the same composite. +fn mixed_region_document(depth: SampleType, opaque: bool) -> Document { + let mut doc = Document::new("CMYK regions", Size::new(48, 16), ColorMode::Cmyk, depth); + let mut bottom = Surface::new(doc.pixel_format()); + bottom.fill_rect(Rect::new(0, 0, 16, 16), &[0.2, 0.6, 0.1, 0.15, 1.0]); + bottom.fill_rect(Rect::new(16, 0, 32, 16), &[0.1, 0.2, 0.7, 0.25, 0.5]); + if opaque { + bottom.fill_rect(doc.bounds(), &[0.2, 0.6, 0.1, 0.15, 1.0]); + } + doc.layers.push(Layer::new("Ink", LayerContent::Raster(bottom))); + let mut surface = Surface::new(doc.pixel_format()); + surface.fill_rect(Rect::new(0, 0, 16, 16), &[0.7, 0.1, 0.3, 0.2, 1.0]); + let mut top = Layer::new("Masked ink", LayerContent::Raster(surface)); + top.opacity = 0.5; + top.mask = Some(LayerMask { + surface: Surface::with_default(PixelFormat::new(ColorMode::Grayscale, depth, false), &[0.5]), + enabled: true, + linked: true, + density: 1.0, + feather: 0.0, + }); + doc.layers.push(top); + doc.resolution_dpi = 300.0; + doc +} + fn tag(bytes: &[u8], id: u16) -> Option { assert_eq!(&bytes[..2], b"II"); let start = usize::try_from(u32::from_le_bytes(bytes[4..8].try_into().unwrap())).unwrap(); @@ -71,6 +97,69 @@ fn tiff_keeps_true_zero_alpha_when_composited() { } } +#[test] +fn flat_and_layered_tiff_agree_for_mixed_cmyk_regions() { + let custom = photocraft_cms::synth::cmyk_profile(&photocraft_cms::synth::CmykParams { + description: "Mixed regions uncoated".into(), + tvi: [0.26, 0.26, 0.26, 0.3], + grid_a2b: 5, + grid_b2a: 9, + ..Default::default() + }); + let coated = Builtin::CoatedCmyk.profile().to_bytes(); + for (name, profile) in [("untagged", None), ("coated", Some(coated.clone())), ("uncoated", Some(custom.to_bytes()))] { + for (depth, sample) in [(SampleType::U8, CodecSample::U8), (SampleType::U16, CodecSample::U16), (SampleType::F32, CodecSample::F32)] { + for opaque in [false, true] { + let context = format!("{name}, {depth:?}, opaque={opaque}"); + let mut doc = mixed_region_document(depth, opaque); + doc.icc_profile = profile.clone(); + let flat = export(&doc, "tiff", &ExportOptions::default()).unwrap(); + let layered = export(&doc, "tiff", &ExportOptions { tiff_layers: true, ..Default::default() }).unwrap(); + let image = decode(&flat.bytes).unwrap(); + let reference = decode(&layered.bytes).unwrap(); + assert!(!flat.warnings.iter().any(|warning| warning.contains("sRGB")), "{context}: {:?}", flat.warnings); + // Flat composites embed the fallback profile; the layered path keeps the source tag. + assert_eq!(image.icc.as_deref(), Some(profile.as_ref().unwrap_or(&coated).as_slice()), "{context}"); + assert_eq!(reference.icc.as_deref(), profile.as_deref().map(Vec::as_slice), "{context}"); + assert!(image.meta.photoshop_layers.is_none(), "{context}"); + assert!(reference.meta.photoshop_layers.is_some(), "{context}"); + for (decoded, bytes) in [(&image, &flat.bytes), (&reference, &layered.bytes)] { + assert_eq!(decoded.dimensions(), (48, 16), "{context}"); + assert_eq!(decoded.layout(), if opaque { ChannelLayout::Cmyk } else { ChannelLayout::CmykA }, "{context}"); + assert_eq!(decoded.sample_type(), sample, "{context}"); + assert_eq!(decoded.meta.dpi, Some((300.0, 300.0)), "{context}"); + assert_eq!(tag(bytes, 262), Some(5), "{context}"); + assert_eq!(tag(bytes, 277), Some(if opaque { 4 } else { 5 }), "{context}"); + assert_eq!(tag(bytes, 338), if opaque { None } else { Some(2) }, "{context}"); + } + // The PSD-backed layered path inverts CMYK samples twice, so allow one stored level. + let tolerance = match depth { + SampleType::U8 => 1.0 / 255.0, + SampleType::U16 => 1.0 / 65535.0, + SampleType::F32 => 1e-6, + }; + let actual = image.to_normalized(); + let expected = reference.to_normalized(); + assert_eq!(actual.len(), expected.len(), "{context}"); + let difference = actual.iter().zip(&expected).map(|(a, b)| (a - b).abs()).fold(0.0f32, f32::max); + assert!(difference <= tolerance + 1e-6, "{context}: maximum sample difference {difference}"); + if !opaque { + for (i, px) in actual.as_chunks::<5>().0.iter().enumerate() { + match i % 48 { + 0..16 => assert_eq!(px[4], 1.0, "{context}: opaque region"), + 16..32 => assert!((px[4] - 0.5).abs() <= tolerance, "{context}: translucent region: {px:?}"), + _ => assert_eq!(px[4], 0.0, "{context}: empty region"), + } + } + } + let back = photocraft_io::import("regions.tiff", &flat.bytes).unwrap().document; + assert_eq!((back.mode, back.depth), (ColorMode::Cmyk, depth), "{context}"); + assert_eq!(back.resolution_dpi, 300.0, "{context}"); + } + } + } +} + #[test] fn psd_keeps_cmyk_mode_depth_and_profile_when_composited() { for depth in [SampleType::U8, SampleType::U16, SampleType::F32] { From 840edc311c8d237b2cfabfb5bc552c3a04a174b6 Mon Sep 17 00:00:00 2001 From: Jason Xiang <77497570+0x6Ason@users.noreply.github.com> Date: Fri, 16 Oct 2026 16:01:52 -0700 Subject: [PATCH 41/85] fix(io): preserve linear grayscale profiles for selected EXR parts (#5867) --- crates/io/src/exr_parts.rs | 10 +-- crates/io/tests/exr_part_profile.rs | 117 ++++++++++++++++++++++++++++ 2 files changed, 122 insertions(+), 5 deletions(-) create mode 100644 crates/io/tests/exr_part_profile.rs diff --git a/crates/io/src/exr_parts.rs b/crates/io/src/exr_parts.rs index 7e25feb984..3e9d2e1154 100644 --- a/crates/io/src/exr_parts.rs +++ b/crates/io/src/exr_parts.rs @@ -181,7 +181,8 @@ fn layer_surface(img: &Image, max: &mut (u32, u32)) -> Result<(Surface, ColorMod } /// Imports the chosen entries as the layers of one document: parts whole, groups from their -/// own channels, F16 kept losslessly as F32, the linear-light EXR values tagged linear sRGB. +/// own channels, F16 kept losslessly as F32, the linear-light EXR values tagged linear sRGB +/// or linear gray. /// `precomp` adds the additive sum of the file's AOV groups as a top layer and fails, /// naming the missing AOVs, when the light-path set is incomplete. pub fn import(name: &str, bytes: &[u8], sel: &Selection) -> Result { @@ -270,10 +271,9 @@ pub fn import(name: &str, bytes: &[u8], sel: &Selection) -> Result Open. + +use photocraft_cms::{Builtin, ColorSpace, Intent, Profile, Transform}; +use photocraft_codecs::{self as codecs, ChannelLayout, Format, Image, SampleType as CodecSample}; +use photocraft_color::{ColorMode, SampleType}; +use photocraft_doc::Document; +use photocraft_io::{ExportOptions, ImportResult, export, exr_parts, import}; + +fn exr(layout: ChannelLayout, depth: CodecSample) -> Vec { + // Exactly representable in half precision, including negative and HDR luminance. + let samples: Vec = [-0.25, 0.25, 0.5, 2.0] + .into_iter() + .zip([1.0, 0.5, 0.25, 0.0]) + .flat_map(|(v, a)| match layout { + ChannelLayout::Gray => vec![v], + ChannelLayout::GrayA => vec![v, a], + ChannelLayout::Rgb => vec![v, 0.125, 0.75], + ChannelLayout::Rgba => vec![v, 0.125, 0.75, a], + _ => panic!("not an EXR layout"), + }) + .collect(); + let image = Image::from_f32(4, 1, layout, &samples).unwrap().convert(layout, depth); + codecs::encode(&image, Format::OpenExr, &Default::default()).unwrap() +} + +fn selected_part(bytes: &[u8]) -> ImportResult { + let entries = exr_parts::entries(bytes).unwrap(); + assert_eq!(entries.len(), 1); + assert_eq!(entries[0].kind, exr_parts::EntryKind::Part); + let selection = exr_parts::Selection { parts: vec![entries[0].name.clone()], groups: vec![], view: "both".into(), precomp: false }; + exr_parts::import("selected", bytes, &selection).unwrap() +} + +fn samples(doc: &Document) -> Vec { + assert_eq!(doc.layers.len(), 1); + doc.layers[0].surface().unwrap().read_region(doc.bounds()) +} + +#[test] +fn selected_gray_exr_part_keeps_linear_profile_and_float_samples() { + for depth in [CodecSample::F16, CodecSample::F32] { + for layout in [ChannelLayout::Gray, ChannelLayout::GrayA] { + let bytes = exr(layout, depth); + let selected = selected_part(&bytes); + let ordinary = import("ordinary.exr", &bytes).unwrap(); + let doc = &selected.document; + assert_eq!((doc.mode, doc.depth), (ColorMode::Grayscale, SampleType::F32)); + assert_eq!(doc.size, ordinary.document.size); + assert_eq!(selected.warnings, ordinary.warnings); + assert_eq!(selected.warnings.len(), usize::from(depth == CodecSample::F16)); + let expected = codecs::decode(&bytes).unwrap().convert(ChannelLayout::GrayA, CodecSample::F32).to_f32_samples().unwrap(); + assert_eq!(samples(doc), expected, "{layout:?} {depth:?}: samples and alpha stay unchanged"); + assert_eq!(samples(doc), samples(&ordinary.document)); + assert_eq!(doc.icc_profile, Some(photocraft_cms::builtin::linear_gray().to_bytes()), "{layout:?} {depth:?}: linear luminance"); + assert_eq!(doc.icc_profile, ordinary.document.icc_profile); + + // The tagged samples convert to display gray, rather than being interpreted as sGray. + let profile = Profile::parse(doc.icc_profile.as_deref().unwrap()).unwrap(); + assert_eq!(profile.color_space, ColorSpace::Gray); + let transform = Transform::new(&profile, Builtin::SGray.profile(), Intent::RelativeColorimetric, false).unwrap(); + let mut display = samples(doc); + transform.apply(&mut display, 2); + for i in [1, 2] { + let expected_gray = photocraft_color::convert::linear_to_srgb(expected[i * 2]); + assert!((display[i * 2] - expected_gray).abs() < 1e-4, "{layout:?} {depth:?}: display gray"); + assert_eq!(display[i * 2 + 1], expected[i * 2 + 1], "alpha is not gamma encoded"); + } + } + } +} + +#[test] +fn selected_gray_exr_part_exports_like_ordinary_import() { + for depth in [CodecSample::F16, CodecSample::F32] { + for layout in [ChannelLayout::Gray, ChannelLayout::GrayA] { + let bytes = exr(layout, depth); + let selected = selected_part(&bytes).document; + let ordinary = import("ordinary.exr", &bytes).unwrap().document; + for ext in ["exr", "tga"] { + let output = export(&selected, ext, &ExportOptions::default()).unwrap(); + let control = export(&ordinary, ext, &ExportOptions::default()).unwrap(); + let output = codecs::decode(&output.bytes).unwrap(); + let control = codecs::decode(&control.bytes).unwrap(); + assert_eq!(output.layout(), control.layout()); + assert_eq!(output.to_normalized(), control.to_normalized(), "{layout:?} {depth:?} {ext}: same colours as File > Open"); + let output = output.convert(ChannelLayout::GrayA, CodecSample::F32).to_f32_samples().unwrap(); + let original = samples(&selected); + if ext == "exr" { + assert_eq!(output, original, "linear EXR samples, including negative/HDR and alpha, survive re-export"); + } else { + for i in [1, 2] { + let expected = photocraft_color::convert::linear_to_srgb(original[i * 2]); + assert!((output[i * 2] - expected).abs() <= 1.0 / 255.0, "TGA uses display-encoded gray"); + assert!((output[i * 2 + 1] - original[i * 2 + 1]).abs() <= 1.0 / 255.0, "TGA preserves alpha"); + } + } + } + } + } +} + +#[test] +fn selected_rgb_exr_part_keeps_linear_srgb() { + for depth in [CodecSample::F16, CodecSample::F32] { + for layout in [ChannelLayout::Rgb, ChannelLayout::Rgba] { + let bytes = exr(layout, depth); + let doc = selected_part(&bytes).document; + let ordinary = import("ordinary.exr", &bytes).unwrap().document; + assert_eq!((doc.mode, doc.depth), (ColorMode::Rgb, SampleType::F32)); + assert_eq!(doc.icc_profile, Some(Builtin::LinearSrgb.profile().to_bytes())); + assert_eq!(doc.icc_profile, ordinary.icc_profile); + let expected = codecs::decode(&bytes).unwrap().convert(ChannelLayout::Rgba, CodecSample::F32).to_f32_samples().unwrap(); + assert_eq!(samples(&doc), expected, "{layout:?} {depth:?}: RGB and alpha stay unchanged"); + assert_eq!(samples(&doc), samples(&ordinary)); + } + } +} From f61ed26e14cf7f39fa3fa9f0cddecf420ea8b0af Mon Sep 17 00:00:00 2001 From: Jason Xiang <77497570+0x6Ason@users.noreply.github.com> Date: Fri, 16 Oct 2026 16:02:23 -0700 Subject: [PATCH 42/85] fix(io): honor version-aware OpenRaster group isolation (#5872) --- crates/io/src/ora.rs | 37 +++++++++++++++--- crates/io/src/ora/tests.rs | 78 ++++++++++++++++++++++++++++++++++++++ docs/ora.md | 12 +++++- 3 files changed, 120 insertions(+), 7 deletions(-) diff --git a/crates/io/src/ora.rs b/crates/io/src/ora.rs index 42b2ab7a33..f365154333 100644 --- a/crates/io/src/ora.rs +++ b/crates/io/src/ora.rs @@ -258,9 +258,23 @@ fn props(node: roxmltree::Node<'_, '_>, layer: &mut Layer, index: usize, warning Ok(()) } +/// OpenRaster 0.0.6 defaults to isolated groups and makes `auto` explicitly pass-through. +/// Older files used inference from the group's blend mode; keep that behavior when the +/// version is absent or unrecognized, too. Compare the numeric core, not version strings. +fn uses_explicit_group_isolation(version: Option<&str>) -> bool { + let Some(core) = version.and_then(|v| v.trim().split(['-', '+']).next()) else { return false }; + let mut parts = core.split('.'); + let number = |part: Option<&str>| part.and_then(|s| s.parse::().ok()); + match (number(parts.next()), number(parts.next()), number(parts.next()), parts.next()) { + (Some(major), Some(minor), Some(patch), None) => [major, minor, patch] >= [0, 0, 6], + _ => false, + } +} + struct Reader<'a, 'c> { zip: ZipReader<'a>, ctl: &'c Interrupt<'c>, + explicit_group_isolation: bool, nodes: usize, budget: usize, warnings: Warnings, @@ -325,10 +339,15 @@ impl Reader<'_, '_> { "stack" => { let mut layer = Layer::group("", Vec::new()); props(child, &mut layer, index, &mut self.warnings)?; - // `auto` (the default) isolates only when something needs it: an - // OpenRaster group without its own blend mode is a pass-through group. - let isolate = child.attribute("isolation").map(str::trim) == Some("isolate"); - if !isolate && layer.blend == BlendMode::Normal { + let isolation = child.attribute("isolation").map(str::trim); + let pass_through = if self.explicit_group_isolation { + // Since 0.0.6 only explicit `auto` passes through, ignoring the + // group's composite-op. An omitted isolation means `isolate`. + isolation == Some("auto") + } else { + isolation != Some("isolate") && layer.blend == BlendMode::Normal + }; + if pass_through { layer.blend = BlendMode::PassThrough; } let children = self.stack(child, depth + 1)?; @@ -415,7 +434,15 @@ pub(crate) fn import(name: &str, bytes: &[u8], ctl: &Interrupt<'_>) -> Result"))?; - let mut reader = Reader { zip, ctl, nodes: 0, budget: MAX_PIXEL_BYTES, warnings: Warnings::default(), images: Vec::new() }; + let mut reader = Reader { + zip, + ctl, + explicit_group_isolation: uses_explicit_group_isolation(image.attribute("version")), + nodes: 0, + budget: MAX_PIXEL_BYTES, + warnings: Warnings::default(), + images: Vec::new(), + }; let nodes = reader.stack(root, 1)?; ctl.progress(0.8); let depth = document_depth(&reader.images); diff --git a/crates/io/src/ora/tests.rs b/crates/io/src/ora/tests.rs index 319414a94d..4f0b1c4c47 100644 --- a/crates/io/src/ora/tests.rs +++ b/crates/io/src/ora/tests.rs @@ -205,6 +205,84 @@ fn gray16_png(w: u32, h: u32) -> Vec { codecs::encode(&Image::from_raw(w, h, ChannelLayout::Gray, CSample::U16, data).unwrap(), Format::Png, &Default::default()).unwrap() } +/// A green Multiply layer over a red backdrop distinguishes isolation (green) from +/// pass-through (black), even though both groups use Normal compositing by default. +fn group_isolation_document(version: Option<&str>, attributes: &str, depth: SampleType) -> Document { + let version = version.map(|v| format!("version=\"{v}\"")).unwrap_or_default(); + let xml = format!( + r#" + + + + + "# + ); + let fmt = PixelFormat::new(ColorMode::Rgb, depth, true); + let png = |rgba| { + let image = Image::from_raw(1, 1, ChannelLayout::Rgba, csample(depth), px(fmt, rgba)).unwrap(); + codecs::encode(&image, Format::Png, &Default::default()).unwrap() + }; + let bytes = archive(&xml, &[("green.png", png([0.0, 1.0, 0.0, 1.0])), ("red.png", png([1.0, 0.0, 0.0, 1.0]))]); + let result = crate::import("isolation.ora", &bytes).unwrap(); + assert!(result.warnings.is_empty(), "{:?}", result.warnings); + assert_eq!(result.document.depth, depth); + result.document +} + +#[test] +fn omitted_group_isolation_defaults_to_isolated_since_version_0_0_6() { + for depth in [SampleType::U8, SampleType::U16] { + for version in ["0.0.6", " 0.0.6 ", "0.0.6+app.1", "0.0.6-rc.1", "0.0.10", "0.1.0", "1.0.0"] { + let d = group_isolation_document(Some(version), "", depth); + assert_eq!(d.layers[1].blend, BlendMode::Normal, "{version}, {depth:?}"); + assert_eq!(photocraft_compose::flatten(&d).px, [[0.0, 1.0, 0.0, 1.0]], "{version}, {depth:?}"); + } + } +} + +#[test] +fn explicit_group_isolation_preserves_isolate_and_auto() { + for depth in [SampleType::U8, SampleType::U16] { + for version in ["0.0.3", "0.0.5", "0.0.6"] { + for (attributes, blend, expected) in [ + ("isolation=' isolate '", BlendMode::Normal, [0.0, 1.0, 0.0, 1.0]), + ("isolation=' auto '", BlendMode::PassThrough, [0.0, 0.0, 0.0, 1.0]), + ("isolation='isolate' composite-op='svg:multiply'", BlendMode::Multiply, [0.0, 0.0, 0.0, 1.0]), + ] { + let d = group_isolation_document(Some(version), attributes, depth); + assert_eq!(d.layers[1].blend, blend, "{version}, {attributes}, {depth:?}"); + assert_eq!(photocraft_compose::flatten(&d).px, [expected], "{version}, {attributes}, {depth:?}"); + } + } + } +} + +#[test] +fn auto_group_ignores_its_composite_op_since_version_0_0_6() { + for depth in [SampleType::U8, SampleType::U16] { + let d = group_isolation_document(Some("0.0.6"), "isolation='auto' composite-op='svg:screen'", depth); + assert_eq!(d.layers[1].blend, BlendMode::PassThrough); + assert_eq!(photocraft_compose::flatten(&d).px, [[0.0, 0.0, 0.0, 1.0]], "{depth:?}"); + } +} + +#[test] +fn legacy_group_isolation_keeps_inference_from_blend_mode() { + for depth in [SampleType::U8, SampleType::U16] { + for version in [None, Some("0.0.3"), Some("0.0.5"), Some("unknown"), Some("0.0"), Some("0.0.6.0")] { + for (attributes, blend, expected) in [ + ("", BlendMode::PassThrough, [0.0, 0.0, 0.0, 1.0]), + ("composite-op='svg:screen'", BlendMode::Screen, [1.0, 1.0, 0.0, 1.0]), + ("isolation='auto' composite-op='svg:screen'", BlendMode::Screen, [1.0, 1.0, 0.0, 1.0]), + ] { + let d = group_isolation_document(version, attributes, depth); + assert_eq!(d.layers[1].blend, blend, "{version:?}, {attributes}, {depth:?}"); + assert_eq!(photocraft_compose::flatten(&d).px, [expected], "{version:?}, {attributes}, {depth:?}"); + } + } + } +} + #[test] fn reads_other_applications_stacks() { // The shapes Krita, MyPaint and GIMP write: a root stack with attributes, `auto` diff --git a/docs/ora.md b/docs/ora.md index 19f3c7503e..2bf4f5112d 100644 --- a/docs/ora.md +++ b/docs/ora.md @@ -12,7 +12,7 @@ entry, so an OpenRaster file with another extension still opens as one. |---|---|---| | Pixel layers (any size and position, also off-canvas) | yes | yes, cropped to their pixels | | Groups, nested | yes | yes | -| Pass-through groups | `isolation="auto"` without a blend mode | `isolation="auto"` | +| Pass-through groups | `isolation="auto"` (before 0.0.6, only without a blend mode) | `isolation="auto"` | | Names, visibility, opacity | yes | yes | | Blend modes | the `svg:*` names, `svg:plus`, and Krita's `krita:*` names | `svg:*` where the spec has the mode, else `krita:*` (read back by Krita, Normal elsewhere) | | Locks | `edit-locked`, `alpha-preserve` | the same | @@ -20,6 +20,12 @@ entry, so an OpenRaster file with another extension still opens as one. | Resolution | `xres` | `xres` and `yres` | | Merged image and thumbnail | not read (we render our own) | written (`mergedimage.png`, `Thumbnails/thumbnail.png`) | +Files declaring OpenRaster 0.0.6 or newer default to isolated groups; an explicit +`isolation="auto"` passes through regardless of the group's own composite operation. Older +files and files without a recognized version keep the legacy inference from the group's +blend mode. Pass-through group opacity still uses PhotoCraft's interpolation against the +backdrop, which can differ from 0.0.6's per-child opacity multiplication for overlapping children. + Saving reports what OpenRaster can't hold, layer by layer: pixel masks are applied to the layer's pixels, fill opacity is folded into the pixels, text, shape, fill and smart object layers are written as their rendered pixels, and adjustment layers, layer styles, vector masks, @@ -53,7 +59,9 @@ are covered by tests. Unit tests (`crates/io/src/ora/tests.rs`) round-trip layers, groups, pass-through, offsets, opacity, visibility, locks and every blend mode at 8 and 16 bit, check masks, rendered layer -kinds, the archive layout and malformed input. `crates/automation/tests/openraster.rs` saves, +kinds, the archive layout and malformed input. Synthetic 8/16-bit pixel fixtures check modern +and legacy group-isolation defaults, explicit isolation and pass-through. +`crates/automation/tests/openraster.rs` saves, reopens, edits and saves in place through the agent surface, and checks that a document with an adjustment layer never overwrites its `.ora`; the CLI test converts both ways. From 06ad07ab9b1a90b9ab2ec8e923e74a5b1640b4c1 Mon Sep 17 00:00:00 2001 From: Jason Xiang <77497570+0x6Ason@users.noreply.github.com> Date: Fri, 16 Oct 2026 16:02:54 -0700 Subject: [PATCH 43/85] fix(io): color-manage grayscale WebP exports (#5875) --- crates/io/src/flat.rs | 7 +++-- crates/io/tests/flat.rs | 67 +++++++++++++++++++++++++++++++++++++++++ 2 files changed, 71 insertions(+), 3 deletions(-) diff --git a/crates/io/src/flat.rs b/crates/io/src/flat.rs index f61c28a40b..a18367467a 100644 --- a/crates/io/src/flat.rs +++ b/crates/io/src/flat.rs @@ -559,9 +559,10 @@ pub(crate) fn export_flat_with(doc: &Document, format: Format, opts: &ExportOpti } else if let Some(linear) = convert_gray(&img, photocraft_cms::builtin::linear_gray(), CSample::F32)? { img = linear; } - } else if !format.caps().icc { - // Untagged files read back as sRGB/sGray: convert to it (as Quick Export does) rather than - // write values that only mean something under the dropped profile. + } else if !format.caps().icc || (img.layout().is_gray() && !format.caps().layouts.iter().any(|l| l.is_gray())) { + // Untagged files read back as sRGB/sGray. Even an ICC-capable format like WebP loses a + // gray profile when the codec expands gray to RGB. Convert first (as Quick Export does) + // rather than write values that only mean something under the dropped profile. if let Some(srgb) = convert_rgb(&img, Builtin::Srgb.profile(), Intent::Perceptual, true, img.sample_type())? { img = srgb; warnings.push(format!("colours converted to sRGB; {format:?} can't embed the document's colour profile")); diff --git a/crates/io/tests/flat.rs b/crates/io/tests/flat.rs index b82b41ffc3..8cdecde369 100644 --- a/crates/io/tests/flat.rs +++ b/crates/io/tests/flat.rs @@ -610,6 +610,73 @@ fn linear_gray_export_preserves_alpha_and_embeddable_profiles() { } } +/// WebP can embed RGB profiles, but expanding gray to RGB drops a gray profile. Its untagged +/// RGB samples must use the sRGB transfer function, not the source gray encoding. +#[test] +fn linear_gray_webp_exports_use_display_encoding() { + use photocraft_codecs::{ChannelLayout, EncodeOptions}; + for depth in [SampleType::U8, SampleType::U16, SampleType::F32] { + for alpha in [1.0, 0.5] { + let mut d = columns(ColorMode::Grayscale, depth, &[&[0.25, alpha], &[0.5, alpha]]); + d.icc_profile = Some(photocraft_cms::builtin::linear_gray().to_bytes()); + let stored = column_pixels(&d, 2); + for lossless in [true, false] { + let opts = ExportOptions { encode: EncodeOptions { webp_lossless: lossless, webp_quality: 100, ..Default::default() }, ..Default::default() }; + let r = export(&d, "webp", &opts).unwrap(); + let img = photocraft_codecs::decode(&r.bytes).unwrap(); + let what = format!("{depth:?}, alpha {alpha}, lossless {lossless}"); + assert_eq!(img.layout(), if alpha == 1.0 { ChannelLayout::Rgb } else { ChannelLayout::Rgba }, "{what}"); + assert_eq!(img.icc, None, "{what}: a gray ICC cannot describe RGB samples"); + let values = img.to_normalized(); + let stride = img.layout().channels(); + let tolerance = if lossless { 1.5 / 255.0 } else { 3.0 / 255.0 }; + for (i, src) in stored.iter().enumerate() { + let offset = (8 * img.width() as usize + 16 * i + 8) * stride; + let expected = photocraft_color::convert::linear_to_srgb(src[0]); + for &value in &values[offset..offset + 3] { + assert!((value - expected).abs() <= tolerance, "{what}, pixel {i}: got {value}, expected {expected}"); + } + if img.layout().has_alpha() { + assert!((values[offset + 3] - src[1]).abs() <= 1.0 / 255.0, "{what}: alpha changed"); + } + } + assert!(r.warnings.iter().any(|w| w.contains("converted to sGray")), "{what}: {:?}", r.warnings); + } + } + } +} + +#[test] +fn default_gray_webp_exports_keep_samples() { + for depth in [SampleType::U8, SampleType::U16, SampleType::F32] { + for icc in [None, Some(photocraft_cms::Builtin::SGray.profile().to_bytes())] { + let mut d = columns(ColorMode::Grayscale, depth, &[&[0.25, 0.5], &[0.5, 1.0]]); + d.icc_profile = icc; + let stored = column_pixels(&d, 2); + let r = export(&d, "webp", &ExportOptions::default()).unwrap(); + let img = photocraft_codecs::decode(&r.bytes).unwrap(); + assert_eq!(img.icc, None, "{depth:?}: a gray ICC cannot describe RGB samples"); + let back = import("gray.webp", &r.bytes).unwrap().document; + let want: Vec> = stored.iter().map(|p| vec![p[0], p[0], p[0], p[1]]).collect(); + assert_colors(&column_pixels(&back, 2), &want, 1.0 / 255.0, &format!("{depth:?}: sGray samples")); + assert!(!r.warnings.iter().any(|w| w.contains("converted to sGray")), "{depth:?}: {:?}", r.warnings); + } + } +} + +#[test] +fn rgb_webp_exports_keep_embeddable_profiles() { + for depth in [SampleType::U8, SampleType::U16, SampleType::F32] { + let mut d = columns(ColorMode::Rgb, depth, &[&[0.25, 0.5, 0.75, 0.5]]); + d.icc_profile = Some(photocraft_cms::Builtin::LinearSrgb.profile().to_bytes()); + let r = export(&d, "webp", &ExportOptions::default()).unwrap(); + let back = import("rgb.webp", &r.bytes).unwrap().document; + assert_eq!(back.icc_profile, d.icc_profile, "{depth:?}: WebP keeps RGB ICC"); + assert_colors(&column_pixels(&back, 1), &column_pixels(&d, 1), 1.0 / 255.0, &format!("{depth:?}: RGB samples")); + assert!(!r.warnings.iter().any(|w| w.contains("converted to sRGB")), "{depth:?}: {:?}", r.warnings); + } +} + /// #518: a JPEG cut off inside its image data opens with a warning, never silently. #[test] fn truncated_jpeg_imports_with_a_warning() { From db46713c2ca0140e119956ed695cf92837fc50e3 Mon Sep 17 00:00:00 2001 From: Marco Arnulfo <103679419+marcoarnulfo@users.noreply.github.com> Date: Sat, 17 Oct 2026 01:03:15 +0200 Subject: [PATCH 44/85] Unsaved-changes prompt: the default answer takes keyboard focus, so arrows and Enter work (part of #6441) (#6466) --- crates/ui-egui/src/discard_ui.rs | 74 ++++++++++++++++++++++++-------- crates/ui-egui/src/widgets.rs | 13 +++++- 2 files changed, 69 insertions(+), 18 deletions(-) diff --git a/crates/ui-egui/src/discard_ui.rs b/crates/ui-egui/src/discard_ui.rs index 30e5f47deb..6720015458 100644 --- a/crates/ui-egui/src/discard_ui.rs +++ b/crates/ui-egui/src/discard_ui.rs @@ -21,6 +21,8 @@ pub struct Prompt { /// The document the command was aimed at (`document` param, else the active one), if it has one. target: Option, docs: Vec, + /// The document whose question has already put keyboard focus on its default answer. + focused: Option, } fn index_of(app: &PhotocraftApp, id: DocId) -> Option { @@ -69,7 +71,7 @@ pub fn intercept(app: &mut PhotocraftApp, id: &str, params: &Value) -> bool { if docs.is_empty() { return false; } - let prompt = Prompt { id: id.to_string(), params: params.clone(), target, docs }; + let prompt = Prompt { id: id.to_string(), params: params.clone(), target, docs, focused: None }; match &app.discard { None => ask(app, prompt), // Quitting overrides whatever is pending: it covers every document, so nothing is lost. @@ -226,6 +228,10 @@ pub fn show(app: &mut PhotocraftApp, ctx: &egui::Context) { // Save can ask for a path and then TIFF or Targa Options, and then write in the background. // Wait for all three; cancellation or a failed save brings us back. if app.file_dialog_open() || app.save_options_open() || app.saving() { + // Hidden meanwhile: the question takes focus again when it comes back. + if let Some(p) = app.discard.as_mut() { + p.focused = None; + } return; } let Some(p) = &app.discard else { return }; @@ -246,6 +252,12 @@ pub fn show(app: &mut PhotocraftApp, ctx: &egui::Context) { }; (tl!("Unsaved changes"), crate::i18n::fmt(template, &[("name", &name)])) }; + // Each question comes up with its default answer focused, as in Photoshop: the arrow keys then + // move between the answers and Enter presses the focused one (#6441). + let focus = p.focused != Some(doc); + if let Some(p) = app.discard.as_mut() { + p.focused = Some(doc); + } let mac = ctx.os() == egui::os::OperatingSystem::Mac; // The answers in the platform's words; `dialog_buttons` puts them in its order. Windows (and // Linux) ask Yes / No / Cancel with Y, N and Esc, as Photoshop does there; macOS asks @@ -266,7 +278,8 @@ pub fn show(app: &mut PhotocraftApp, ctx: &egui::Context) { }; let mut answer = ctx.input_mut(|i| buttons.iter().find(|b| b.2.is_some_and(|k| i.consume_key(egui::Modifiers::NONE, k))).map(|b| b.4)); let labels = button_labels(mac, &buttons.iter().map(|b| (b.1, b.2)).collect::>()); - let row: Vec = buttons.iter().zip(&labels).map(|(b, label)| DialogButton::new(b.0, label, b.3)).collect(); + let row: Vec = + buttons.iter().zip(&labels).map(|(b, label)| DialogButton::new(b.0, label, b.3).focus(focus && b.0 == ButtonRole::Default)).collect(); let modal = egui::Modal::new(egui::Id::new("discard-prompt")).show(ctx, |ui| { ui.set_max_width(420.0); ui.label(egui::RichText::new(tl!(&title)).font(crate::theme::semibold(15.0))); @@ -582,31 +595,60 @@ mod tests { v.into_iter().map(|(_, l)| l).collect() } - /// Tab walks `labels` left to right and wraps; Shift+Tab steps back. - fn tab_walks(h: &mut Prompted, labels: [&str; 3]) { - use egui_kittest::kittest::Queryable; - let focused = |h: &Prompted| labels.into_iter().find(|l| h.get_by_label(l).is_focused()); - for want in [labels[0], labels[1], labels[2], labels[0]] { + /// Tab walks `labels` left to right from the focused default answer, `labels[default]`, and + /// wraps; Shift+Tab steps back. + fn tab_walks(h: &mut Prompted, labels: [&str; 3], default: usize) { + assert_eq!(focused(h, &labels), Some(labels[default])); + for step in 1..=3 { h.key_press(Key::Tab); h.run_steps(2); - assert_eq!(focused(h), Some(want)); + assert_eq!(focused(h, &labels), Some(labels[(default + step) % 3])); } h.key_press_modifiers(egui::Modifiers::SHIFT, Key::Tab); h.run_steps(2); - assert_eq!(focused(h), Some(labels[2])); + assert_eq!(focused(h, &labels), Some(labels[(default + 2) % 3])); } fn docs_left(h: &Prompted) -> Option { h.state().discard.as_ref().map(|p| p.docs.len()) } + /// Which of `labels` has keyboard focus. + fn focused<'a>(h: &Prompted, labels: &[&'a str]) -> Option<&'a str> { + use egui_kittest::kittest::Queryable; + labels.iter().copied().find(|l| h.get_by_label(l).is_focused()) + } + + /// The default answer has keyboard focus as each question comes up, so the arrow keys move + /// between the answers and Enter presses the one they reached, as in Photoshop (#6441). + #[test] + fn each_question_comes_up_with_the_default_answer_focused() { + let mut h = prompt_on(egui::os::OperatingSystem::Windows); + let labels = ["(Y)es", "(N)o", "(C)ancel"]; + assert_eq!(focused(&h, &labels), Some("(Y)es")); + h.key_press(Key::ArrowRight); + h.run_steps(2); + assert_eq!(focused(&h, &labels), Some("(N)o")); + h.key_press(Key::Enter); + h.run_steps(2); + assert_eq!(docs_left(&h), Some(1), "Enter pressed No"); + assert_eq!(focused(&h, &labels), Some("(Y)es"), "the next question starts on the default again"); + + let mut h = prompt_on(egui::os::OperatingSystem::Mac); + let labels = ["Don't Save", "Cancel", "Save"]; + assert_eq!(focused(&h, &labels), Some("Save")); + h.key_press(Key::ArrowLeft); + h.run_steps(2); + assert_eq!(focused(&h, &labels), Some("Cancel")); + } + #[test] fn windows_and_linux_ask_yes_no_cancel_with_the_default_first() { for os in [egui::os::OperatingSystem::Windows, egui::os::OperatingSystem::Nix] { let mut h = prompt_on(os); let labels = ["(Y)es", "(N)o", "(C)ancel"]; assert_eq!(drawn_order(&h, labels), labels, "{os:?}"); - tab_walks(&mut h, labels); + tab_walks(&mut h, labels, 0); h.key_press(Key::N); h.run_steps(2); assert_eq!(docs_left(&h), Some(1), "N answered the first document"); @@ -699,7 +741,7 @@ mod tests { let mut h = prompt_on(egui::os::OperatingSystem::Mac); let labels = ["Don't Save", "Cancel", "Save"]; assert_eq!(drawn_order(&h, labels), labels); - tab_walks(&mut h, labels); + tab_walks(&mut h, labels, 2); h.key_press(Key::D); h.run_steps(2); assert_eq!(docs_left(&h), Some(1), "D answered the first document"); @@ -736,11 +778,9 @@ mod tests { h.run_steps(2); assert_eq!(asked.borrow().len(), 1, "Enter answered Yes, which saves"); assert_eq!(docs_left(&h), Some(2)); - // Tab to No: Enter now presses it. - for _ in 0..2 { - h.key_press(Key::Tab); - h.run_steps(2); - } + // Tab from the focused Yes to No: Enter now presses it. + h.key_press(Key::Tab); + h.run_steps(2); h.key_press(Key::Enter); h.run_steps(2); assert_eq!(asked.borrow().len(), 1, "no second dialog"); @@ -776,7 +816,7 @@ mod tests { assert!(h.query_by_label("(Y)es").is_none(), "the prompt waits behind the dialog"); answer(&mut h, None); assert_eq!(docs_left(&h), Some(1)); - assert!(h.query_by_label("(Y)es").is_some()); + assert_eq!(focused(&h, &["(Y)es", "(N)o", "(C)ancel"]), Some("(Y)es"), "the prompt comes back with Yes focused"); // Saving closes the document. h.key_press(Key::Y); h.run_steps(3); diff --git a/crates/ui-egui/src/widgets.rs b/crates/ui-egui/src/widgets.rs index 7235790cbe..48d7cc5ad0 100644 --- a/crates/ui-egui/src/widgets.rs +++ b/crates/ui-egui/src/widgets.rs @@ -1193,16 +1193,24 @@ pub struct DialogButton<'a> { pub label: &'a str, pub min_width: f32, pub enabled: bool, + /// Take keyboard focus this frame (see [`DialogButton::focus`]). + pub focus: bool, } impl<'a> DialogButton<'a> { pub fn new(role: ButtonRole, label: &'a str, min_width: f32) -> Self { - Self { role, label, min_width, enabled: true } + Self { role, label, min_width, enabled: true, focus: false } } pub fn enabled(self, enabled: bool) -> Self { Self { enabled, ..self } } + + /// Asks for keyboard focus for this button: from there the arrow keys move between the row's + /// buttons and Enter or Space presses the focused one. + pub fn focus(self, focus: bool) -> Self { + Self { focus, ..self } + } } /// A dialog's button row at the cursor, in the platform's order (see [`ButtonRole`]): every modal @@ -1225,6 +1233,9 @@ pub fn dialog_buttons(ui: &mut Ui, buttons: &[DialogButton]) -> Option Date: Sat, 17 Oct 2026 07:13:51 +0700 Subject: [PATCH 45/85] =?UTF-8?q?fix(ui):=20Layer=20=E2=80=BA=20New=20Fill?= =?UTF-8?q?=20Layer=20=E2=80=BA=20Solid=20Color=20opens=20the=20Color=20Pi?= =?UTF-8?q?cker,=20creating=20the=20layer=20on=20OK=20(#5144)=20(#5166)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Creating a Solid Color fill from the Layer menu or the Layers panel footer now opens the shared Color Picker seeded with the foreground colour. The layer is created on OK in one history step; Cancel leaves the document and history untouched. Parameterised calls (CLI, MCP, actions) still create the layer directly. Fixes #5144. --- crates/ui-egui/src/color_picker_ui.rs | 3 + crates/ui-egui/src/menus.rs | 7 +++ crates/ui-egui/src/panels.rs | 8 ++- crates/ui-egui/src/solid_fill_ui.rs | 30 +++++++++ crates/ui-egui/src/solid_fill_ui/tests.rs | 76 +++++++++++++++++++++++ 5 files changed, 121 insertions(+), 3 deletions(-) diff --git a/crates/ui-egui/src/color_picker_ui.rs b/crates/ui-egui/src/color_picker_ui.rs index 09a3ce8d4c..05c4dd5c17 100644 --- a/crates/ui-egui/src/color_picker_ui.rs +++ b/crates/ui-egui/src/color_picker_ui.rs @@ -783,6 +783,9 @@ pub fn confirm(app: &mut PhotocraftApp, f: &Map) -> Result { + let dialog = crate::solid_fill_ui::open_new(app).ok_or("no document is open")?; + Ok(json!({"dialog": dialog})) + } // Solid Color fills and solid-colour shapes use the Color Picker, Pattern fills the Pattern // Fill dialog; other adjustment / fill / shape controls use Properties. "layer.layerContentOptions" => { diff --git a/crates/ui-egui/src/panels.rs b/crates/ui-egui/src/panels.rs index 7f2eadd084..177ad521ba 100644 --- a/crates/ui-egui/src/panels.rs +++ b/crates/ui-egui/src/panels.rs @@ -2607,10 +2607,12 @@ fn layers(app: &mut PhotocraftApp, ui: &mut egui::Ui) { app.ui.dock.collapsed.retain(|g| *g != crate::dock::Group::Properties); continue; } - if p.is_null() { - // Context-menu items behave like their menu-bar twins (dialogs included). + if p.is_null() || id == "layer.newFillLayer.solidColor" { + // Context-menu items and the Layers footer's Solid Color… action behave like + // their menu-bar twins. In particular, a new Solid Color fill opens its picker. let ctx = ui.ctx().clone(); - if let Err(e) = crate::menus::invoke(app, &ctx, &id, json!({})) { + let params = if p.is_null() { json!({}) } else { p }; + if let Err(e) = crate::menus::invoke(app, &ctx, &id, params) { app.ui.status = e; app.ui.status_error = true; } diff --git a/crates/ui-egui/src/solid_fill_ui.rs b/crates/ui-egui/src/solid_fill_ui.rs index 2ae44d50c8..5d9112abab 100644 --- a/crates/ui-egui/src/solid_fill_ui.rs +++ b/crates/ui-egui/src/solid_fill_ui.rs @@ -30,6 +30,36 @@ pub(crate) fn open(app: &mut PhotocraftApp) -> Option { Some(color_picker_ui::open_for_command(app, "Color Picker (Solid Color)", color.to_rgb(), cmds::SET, params)) } +/// Creating a Solid Color fill is transactional: the document is unchanged until OK. +/// The picker is seeded with the foreground colour, matching the engine's default. +pub(crate) fn open_new(app: &mut PhotocraftApp) -> Option { + let document = app.session.active()?.doc.id.0; + let foreground = app.session.tools.foreground; + Some(color_picker_ui::open_for_command( + app, + "Color Picker (Solid Color)", + [foreground[0], foreground[1], foreground[2]], + "layer.newFillLayer.solidColor", + json!({"document": document}), + )) +} + +/// A creation picker is distinct from the editor opened by a fill layer's thumbnail. +pub(crate) fn owns_new(f: &Map) -> bool { + f.get("__command").and_then(Value::as_str) == Some("layer.newFillLayer.solidColor") + && f.get("__params").and_then(|p| p.get("document")).and_then(Value::as_u64).is_some() +} + +pub(crate) fn confirm_new(app: &mut PhotocraftApp, f: &Map) -> Result { + let expected = f.get("__params").and_then(|p| p.get("document")).and_then(Value::as_u64).ok_or("missing Solid Color document")?; + let st = app.session.active().ok_or("no document open")?; + if st.doc.id.0 != expected { + return Err("the target document is no longer active".into()); + } + let color = f.get("color").and_then(Value::as_str).ok_or("missing Solid Color colour")?; + app.run("layer.newFillLayer.solidColor", json!({"color": color})) +} + pub(crate) fn owns(f: &Map) -> bool { f.get("__command").and_then(Value::as_str) == Some(cmds::SET) } diff --git a/crates/ui-egui/src/solid_fill_ui/tests.rs b/crates/ui-egui/src/solid_fill_ui/tests.rs index 9b919be934..3317875153 100644 --- a/crates/ui-egui/src/solid_fill_ui/tests.rs +++ b/crates/ui-egui/src/solid_fill_ui/tests.rs @@ -41,6 +41,82 @@ fn row(h: &Harness<'_, PhotocraftApp>, id: LayerId) -> crate::layer_row_ui::RowR crate::layer_row_ui::recorded(&h.ctx).into_iter().find(|r| r.layer == id.0).unwrap() } +#[test] +fn new_solid_color_from_menu_cancel_keeps_document_and_history_unchanged() { + let (mut h, _) = harness(1.0); + let ctx = h.ctx.clone(); + let app = h.state_mut(); + let before = app.session.active().unwrap().doc.clone(); + let active = app.session.active().unwrap().active_layer; + let steps = app.session.active().unwrap().history.entries().len(); + + let opened = crate::menus::invoke(app, &ctx, "layer.newFillLayer.solidColor", json!({})).unwrap(); + assert!(opened.get("layer").is_none(), "no layer exists until OK"); + let dialog = opened["dialog"].as_u64().unwrap(); + let picker = app.ui.dialogs.last().unwrap(); + assert_eq!(picker.id, dialog); + assert!(owns_new(&picker.fields), "new Solid Color fill opens the shared Color Picker"); + assert_eq!(picker.fields["__params"]["document"], before.id.0); + assert!(Arc::ptr_eq(&before, &app.session.active().unwrap().doc)); + assert_eq!(app.session.active().unwrap().history.entries().len(), steps); + + app.ui.dialog_mut(dialog).unwrap().fields.insert("color".into(), json!("#ff8800")); + crate::dialogs::cancel(app, dialog).unwrap(); + assert!(app.ui.dialogs.iter().all(|d| d.id != dialog)); + assert!(Arc::ptr_eq(&before, &app.session.active().unwrap().doc), "Cancel must not create or delete a layer"); + assert_eq!(app.session.active().unwrap().active_layer, active); + assert_eq!(app.session.active().unwrap().history.entries().len(), steps, "Cancel must not record a history entry"); +} + +#[test] +fn new_solid_color_from_menu_ok_creates_one_layer_with_chosen_or_default_color() { + let (mut h, _) = harness(1.0); + let ctx = h.ctx.clone(); + let app = h.state_mut(); + let steps = app.session.active().unwrap().history.entries().len(); + + // OK with the initial foreground colour still creates the fill layer. + let first = crate::menus::invoke(app, &ctx, "layer.newFillLayer.solidColor", json!({})).unwrap(); + let created = crate::dialogs::confirm(app, first["dialog"].as_u64().unwrap()).unwrap(); + let id = LayerId(created["layer"].as_u64().unwrap()); + assert!(matches!(app.session.active().unwrap().doc.layer(id).unwrap().content, LayerContent::Fill(Fill::Solid(_)))); + assert_eq!(app.session.active().unwrap().history.entries().len(), steps + 1); + + let second = crate::menus::invoke(app, &ctx, "layer.newFillLayer.solidColor", json!({})).unwrap(); + let dialog = second["dialog"].as_u64().unwrap(); + app.ui.dialog_mut(dialog).unwrap().fields.insert("color".into(), json!("#ff8800")); + let created = crate::dialogs::confirm(app, dialog).unwrap(); + let id = LayerId(created["layer"].as_u64().unwrap()); + let LayerContent::Fill(Fill::Solid(color)) = app.session.active().unwrap().doc.layer(id).unwrap().content else { + panic!("expected the newly created Solid Color fill"); + }; + assert_eq!(color.to_rgba8()[..3], [255, 136, 0]); + assert_eq!(app.session.active().unwrap().active_layer, Some(id)); + assert_eq!(app.session.active().unwrap().history.entries().len(), steps + 2); + + let dialogs = app.ui.dialogs.len(); + let explicit = crate::menus::invoke(app, &ctx, "layer.newFillLayer.solidColor", json!({"color": "#123456"})).unwrap(); + assert!(explicit["layer"].as_u64().is_some()); + assert!(explicit.get("dialog").is_none()); + assert_eq!(app.ui.dialogs.len(), dialogs, "parameterised creation stays non-interactive"); + assert_eq!(app.session.active().unwrap().history.entries().len(), steps + 3); +} + +#[test] +fn new_solid_color_picker_rejects_a_different_active_document() { + let (mut h, _) = harness(1.0); + let ctx = h.ctx.clone(); + let app = h.state_mut(); + let dialog = crate::menus::invoke(app, &ctx, "layer.newFillLayer.solidColor", json!({})).unwrap()["dialog"].as_u64().unwrap(); + app.ui.dialog_mut(dialog).unwrap().fields.insert("color".into(), json!("#ff8800")); + app.run("file.new", json!({"width": 4, "height": 4})).unwrap(); + let before = app.session.active().unwrap().doc.clone(); + let steps = app.session.active().unwrap().history.entries().len(); + assert!(crate::dialogs::confirm(app, dialog).is_err()); + assert!(Arc::ptr_eq(&before, &app.session.active().unwrap().doc)); + assert_eq!(app.session.active().unwrap().history.entries().len(), steps); +} + #[test] fn only_the_colour_thumbnail_opens_the_solid_fill_picker() { for ppp in [1.0, 2.0] { From c7d2c85faf6c3ff7b6c006d2ba5141e50267481b Mon Sep 17 00:00:00 2001 From: Vikram Bhaskaran Date: Sat, 17 Oct 2026 11:19:03 +1100 Subject: [PATCH 46/85] fix(ui): Color Picker's Only Web Colors snaps every edit and is remembered across pickers (#4995) (#5442) With Only Web Colors on, every picker edit (field, sliders, hex, Lab/CMYK, screen sampler, Current) snaps through the clamped 216-colour ramp. The checkbox is remembered on the Color panel state so the foreground, field, command, Layer Style and type-style pickers open with the same setting. Opening with it on snaps the colour and its original together, so OK without an edit leaves a fill layer unchanged. The hex field follows the colour when another path changes it while focused. Fixes #4995. --- crates/ui-egui/src/color_picker_ui.rs | 90 ++++++++++++++----- crates/ui-egui/src/dialogs.rs | 3 + .../ui-egui/src/layer_style/color_picker.rs | 2 +- crates/ui-egui/src/state.rs | 3 + .../src/type_panels_ui/color_picker.rs | 2 +- 5 files changed, 75 insertions(+), 25 deletions(-) diff --git a/crates/ui-egui/src/color_picker_ui.rs b/crates/ui-egui/src/color_picker_ui.rs index 05c4dd5c17..19272a62ed 100644 --- a/crates/ui-egui/src/color_picker_ui.rs +++ b/crates/ui-egui/src/color_picker_ui.rs @@ -171,7 +171,7 @@ pub fn open(app: &mut PhotocraftApp, target: &str) -> u64 { let c = if target == "background" { app.session.tools.background } else { app.session.tools.foreground }; let rgb = [c[0], c[1], c[2]]; let label = if target == "background" { "Color Picker (Background Color)" } else { "Color Picker (Foreground Color)" }; - let mut fields = initial_fields(target, label, rgb); + let mut fields = fields_with_web(app, target, label, rgb); // A colour that was picked as CMYK shows the inks it was picked as, not what its sRGB stand-in converts to (#4196). if let Some(cmyk) = app.session.tools.picked_cmyk(rgb) { fields.insert("__cmyk".into(), json!(cmyk)); @@ -192,10 +192,23 @@ pub(crate) fn initial_fields(target: &str, label: &str, rgb: [f32; 3]) -> Map Map { + let mut f = initial_fields(target, label, rgb); + f.insert("__webOnly".into(), json!(app.ui.color_panel.web_only)); + if app.ui.color_panel.web_only { + set_rgb(&mut f, rgb, Keep::Nothing); + f.insert("__orig".into(), f["color"].clone()); + } + f +} + /// Open the picker on `rgb` for something other than the tool colours: OK runs `command` with /// `params` plus `"color": "#rrggbb"` (e.g. a gradient stop's colour). pub fn open_for_command(app: &mut PhotocraftApp, label: &str, rgb: [f32; 3], command: &str, params: Value) -> u64 { - let mut f = initial_fields("command", label, rgb); + let mut f = fields_with_web(app, "command", label, rgb); f.insert("__command".into(), json!(command)); f.insert("__params".into(), params); app.ui.open_dialog(DialogKind::Command, f) @@ -204,33 +217,16 @@ pub fn open_for_command(app: &mut PhotocraftApp, label: &str, rgb: [f32; 3], com /// Open the picker on `rgb` for a colour field of another dialog (e.g. Edit › Fill's Color…): OK /// writes `"#rrggbb"` into `field` of dialog `dialog`, which stays open; Cancel leaves it alone. pub fn open_for_field(app: &mut PhotocraftApp, label: &str, rgb: [f32; 3], dialog: u64, field: &str) -> u64 { - let hsv = rgb_to_hsv(rgb); - let mut f = Map::new(); - f.insert("__colorPicker".into(), json!("field")); - f.insert("__label".into(), json!(label)); + let mut f = fields_with_web(app, "field", label, rgb); f.insert("__dialog".into(), json!(dialog)); f.insert("__field".into(), json!(field)); - f.insert("color".into(), json!(hex(rgb))); - f.insert("__orig".into(), json!(hex(rgb))); - f.insert("__hsv".into(), json!(hsv)); - f.insert("__mode".into(), json!("h")); - f.insert("__webOnly".into(), json!(false)); app.ui.open_dialog(DialogKind::Command, f) } /// Open the picker on `rgb` for a colour the shell keeps itself (e.g. the Crop shield's custom /// colour): OK hands `"#rrggbb"` to `target`'s setter in [`confirm`]; Cancel leaves it alone. pub fn open_for_target(app: &mut PhotocraftApp, target: &str, label: &str, rgb: [f32; 3]) -> u64 { - let hsv = rgb_to_hsv(rgb); - let mut f = Map::new(); - f.insert("__colorPicker".into(), json!(target)); - f.insert("__label".into(), json!(label)); - f.insert("color".into(), json!(hex(rgb))); - f.insert("__orig".into(), json!(hex(rgb))); - f.insert("__hsv".into(), json!(hsv)); - f.insert("__mode".into(), json!("h")); - f.insert("__webOnly".into(), json!(false)); - app.ui.open_dialog(DialogKind::Command, f) + app.ui.open_dialog(DialogKind::Command, fields_with_web(app, target, label, rgb)) } /// Set by a dialog body (to the picker's title) to open the Color Picker on its `color` field. @@ -367,7 +363,7 @@ pub fn current(f: &Map) -> Components { fn set_rgb(f: &mut Map, rgb: [f32; 3], keep: Keep) { let web = f.get("__webOnly").and_then(Value::as_bool).unwrap_or(false); - let (rgb, keep) = if web { (rgb.map(|v| (v * 5.0).round() / 5.0), Keep::Nothing) } else { (rgb, keep) }; + let (rgb, keep) = if web { (web_safe(rgb), Keep::Nothing) } else { (rgb, keep) }; f.insert("color".into(), json!(hex(rgb))); for key in ["__hsv", "__lab", "__cmyk"] { f.remove(key); @@ -688,9 +684,14 @@ fn fields(ui: &mut egui::Ui, f: &mut Map, mode: &str, now: &Compo let focused = ui.memory(|m| m.has_focus(id)); let pasted = if focused { take_hex_paste(ui) } else { None }; let typing = if focused { ui.data(|d| d.get_temp::(id)) } else { None }; + // Drop stale typing when another path (web snap, field drag, Current) changed `color`. + let shown = hex(rgb); let mut h = match pasted { Some(c) => hex(c).trim_start_matches('#').to_ascii_uppercase(), - None => typing.unwrap_or_else(|| hex(rgb).trim_start_matches('#').to_ascii_uppercase()), + None => match typing { + Some(t) if parse_hex(&t).is_none() || parse_hex(&t).map(hex).as_deref() == Some(shown.as_str()) => t, + _ => shown.trim_start_matches('#').to_ascii_uppercase(), + }, }; let r = crate::widgets::text_field_ex(ui, id, &mut h, "", 64.0, true, Some(6)); if let Some(c) = pasted { @@ -1044,6 +1045,7 @@ mod tests { h.get_by_label("Only Web Colors").click(); h.run_steps(2); assert_eq!(color(&h), "#003366"); + assert_eq!(h.get_by_role(Role::TextInput).value().as_deref(), Some("003366"), "hex field follows the snap while it has focus"); h.get_by_label("Only Web Colors").click(); h.run_steps(2); h.get_by_label("Click to restore the current colour").click(); @@ -1051,6 +1053,48 @@ mod tests { assert_eq!(color(&h), "#123456"); } + #[test] + fn web_only_snaps_hex_field_and_component_edits() { + let mut f = Map::new(); + f.insert("color".into(), json!("#123456")); + f.insert("__webOnly".into(), json!(true)); + set_rgb(&mut f, parse_hex("#ff8800").unwrap(), Keep::Nothing); + assert_eq!(f["color"], json!("#ff9900")); + set_rgb(&mut f, hsv_to_rgb(40.0, 1.0, 1.0), Keep::Hsv([40.0, 1.0, 1.0])); + assert_eq!(f["color"], json!("#ff9900")); + assert!(f.get("__hsv").is_none(), "kept HSV does not outlive a web snap"); + set_rgb(&mut f, photocraft_color::convert::lab_to_srgb([50.0, 40.0, 20.0]).map(|c| c.clamp(0.0, 1.0)), Keep::Lab([50.0, 40.0, 20.0])); + assert_eq!(not_web_safe(parse_hex(f["color"].as_str().unwrap()).unwrap()), None); + assert!(f.get("__lab").is_none()); + } + + #[test] + fn web_only_survives_reopen_and_other_picker_targets() { + let mut app = PhotocraftApp::new(photocraft_engine::Session::new(), crate::Services::default()); + app.ui.color_panel.web_only = true; + let id = open(&mut app, "foreground"); + let f = &app.ui.dialog_mut(id).unwrap().fields; + assert_eq!(f["__webOnly"], json!(true)); + assert_eq!(f["color"], json!(hex(web_safe([app.session.tools.foreground[0], app.session.tools.foreground[1], app.session.tools.foreground[2]])))); + assert_eq!(f["__orig"], f["color"], "the opening snap moves __orig too, so OK with no edit is unchanged"); + app.ui.dialogs.clear(); + let id = open_for_target(&mut app, "noteColor", "Color Picker", [1.0, 0.53, 0.0]); + let f = &app.ui.dialogs.iter().find(|d| d.id == id).unwrap().fields; + assert_eq!(f["__webOnly"], json!(true)); + assert_eq!(f["color"], json!("#ff9900")); + assert_eq!(f["__orig"], json!("#ff9900"), "a non-web-safe start colour is not committed by opening the picker"); + } + + #[test] + fn sample_at_snaps_when_only_web_colors_is_on() { + let mut app = PhotocraftApp::new(photocraft_engine::Session::new(), crate::Services::default()); + let id = open(&mut app, "foreground"); + let f = &mut app.ui.dialog_mut(id).unwrap().fields; + f.insert("__webOnly".into(), json!(true)); + set_rgb(f, [0.07, 0.2, 0.34], Keep::Nothing); + assert_eq!(f["color"], json!("#003366")); + } + #[test] fn side_buttons_and_keyboard_confirm_or_cancel_without_changing_the_other_color() { for target in ["foreground", "background"] { diff --git a/crates/ui-egui/src/dialogs.rs b/crates/ui-egui/src/dialogs.rs index 013c79e825..182b4be8c7 100644 --- a/crates/ui-egui/src/dialogs.rs +++ b/crates/ui-egui/src/dialogs.rs @@ -334,6 +334,9 @@ pub fn show(app: &mut PhotocraftApp, ctx: &egui::Context) { DialogKind::Command if crate::color_picker_ui::owns(&fields) => { outcome = crate::color_picker_ui::body(ui, &mut fields); crate::color_picker_ui::take_add_swatch(app, &mut fields); + if let Some(web) = fields.get("__webOnly").and_then(Value::as_bool) { + app.ui.color_panel.web_only = web; + } } DialogKind::Command if crate::color_range_ui::owns(&fields) => crate::color_range_ui::body(app, ui, &mut fields), DialogKind::Command if crate::prefs_ui::owns(&fields) => crate::prefs_ui::body(app, ui, &mut fields), diff --git a/crates/ui-egui/src/layer_style/color_picker.rs b/crates/ui-egui/src/layer_style/color_picker.rs index b0edb16379..341e659c9e 100644 --- a/crates/ui-egui/src/layer_style/color_picker.rs +++ b/crates/ui-egui/src/layer_style/color_picker.rs @@ -92,7 +92,7 @@ pub(crate) fn open(app: &mut PhotocraftApp, parent: u64, effect: &str, field: &s let t = Target { parent, document: st.doc.id, layer, effect: effect.into(), field: field.into(), color: color.clone(), source }; validate(app, &t)?; let rgb = color_picker_ui::parse_hex(&color).ok_or("invalid effect color")?; - let mut fields = color_picker_ui::initial_fields("dialog", "Color Picker (Layer Style Color)", rgb); + let mut fields = color_picker_ui::fields_with_web(app, "dialog", "Color Picker (Layer Style Color)", rgb); fields.insert(TARGET.into(), serde_json::to_value(t).map_err(|e| e.to_string())?); Ok(app.ui.open_dialog(DialogKind::Command, fields)) } diff --git a/crates/ui-egui/src/state.rs b/crates/ui-egui/src/state.rs index 5cbda434e7..373b6a4794 100644 --- a/crates/ui-egui/src/state.rs +++ b/crates/ui-egui/src/state.rs @@ -1054,6 +1054,9 @@ pub struct DockTabs { pub struct ColorPanelState { /// The panel edits the background colour (its chip was clicked), not the foreground. pub background: bool, + /// Color Picker › Only Web Colors, kept across opens like Photoshop (#4995). + #[serde(default)] + pub web_only: bool, } #[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] diff --git a/crates/ui-egui/src/type_panels_ui/color_picker.rs b/crates/ui-egui/src/type_panels_ui/color_picker.rs index 3c68f72e4c..7df76c0cd5 100644 --- a/crates/ui-egui/src/type_panels_ui/color_picker.rs +++ b/crates/ui-egui/src/type_panels_ui/color_picker.rs @@ -82,7 +82,7 @@ pub(crate) fn open(app: &mut PhotocraftApp, kind: Kind, id: u32) -> Result Date: Sat, 17 Oct 2026 05:50:53 +0530 Subject: [PATCH 47/85] fix(ui): Type tool caret stays solid after typing, navigation or a click, then resumes blinking (#5511) (#5512) The caret's blink phase was tied to the wall clock, so an arrow key or keystroke that landed in the off half-cycle moved an invisible caret. A transient caret_blink_time on UiState is reset on every handled key event, on a click that places the caret, and while dragging; draw_overlay computes the phase from it so the caret is solid for 530 ms after each interaction, and the repaint is scheduled to the next phase boundary. Fixes #5511. --- crates/ui-egui/src/canvas.rs | 1 + crates/ui-egui/src/lib.rs | 2 +- crates/ui-egui/src/state.rs | 4 +++ crates/ui-egui/src/type_tool.rs | 62 +++++++++++++++++++++++++++++++-- 4 files changed, 65 insertions(+), 4 deletions(-) diff --git a/crates/ui-egui/src/canvas.rs b/crates/ui-egui/src/canvas.rs index acbf95063f..ad2ddafa55 100644 --- a/crates/ui-egui/src/canvas.rs +++ b/crates/ui-egui/src/canvas.rs @@ -5498,6 +5498,7 @@ pub fn tool_event(app: &mut PhotocraftApp, ev: ToolEvent, mods: egui::Modifiers) { e.dragging = false; e.resize = None; + app.ui.caret_blink_time = app.last_frame_time; } if tool == Tool::Pen { crate::vector_ui::pen_up(app); diff --git a/crates/ui-egui/src/lib.rs b/crates/ui-egui/src/lib.rs index 4e82be7107..e68a6d555b 100644 --- a/crates/ui-egui/src/lib.rs +++ b/crates/ui-egui/src/lib.rs @@ -657,7 +657,7 @@ pub struct PhotocraftApp { /// The document each of `ui.views` belongs to, as of the last [`Self::sync_views`]. view_docs: Vec, pub fps: f32, - last_frame_time: f64, + pub(crate) last_frame_time: f64, thumbs: HashMap<(photocraft_doc::LayerId, u8), (u64, egui::TextureHandle)>, /// The document area each layer thumbnail in `thumbs` frames (the canvas, or the layer's /// bounds with Clip Thumbnails to Layer Bounds; #4281). diff --git a/crates/ui-egui/src/state.rs b/crates/ui-egui/src/state.rs index 373b6a4794..d464cb3cc8 100644 --- a/crates/ui-egui/src/state.rs +++ b/crates/ui-egui/src/state.rs @@ -1076,6 +1076,9 @@ pub struct UiState { /// Inline type editing session, if any. #[serde(default)] pub text_edit: Option, + /// Clock time in seconds when type cursor blink was last reset (e.g. on keypress/caret move). + #[serde(skip)] + pub caret_blink_time: f64, /// Tool to restore when an inline type edit session commits or cancels (e.g. Move tool, #1132). #[serde(skip)] pub text_edit_return_tool: Option, @@ -1310,6 +1313,7 @@ impl Default for UiState { spring: None, recent_files: Vec::new(), text_edit: None, + caret_blink_time: 0.0, text_edit_return_tool: None, type_transform: None, type_transform_pivot: None, diff --git a/crates/ui-egui/src/type_tool.rs b/crates/ui-egui/src/type_tool.rs index d73bb2e2c4..a8125201d2 100644 --- a/crates/ui-egui/src/type_tool.rs +++ b/crates/ui-egui/src/type_tool.rs @@ -301,6 +301,7 @@ pub fn pointer_up(app: &mut PhotocraftApp, start: [f64; 2], end: [f64; 2]) { if let Some(e) = app.ui.text_edit.as_mut() { e.dragging = false; e.resize = None; + app.ui.caret_blink_time = app.last_frame_time; return; } let (w, h) = ((end[0] - start[0]).abs(), (end[1] - start[1]).abs()); @@ -340,6 +341,7 @@ pub fn pointer_up(app: &mut PhotocraftApp, start: [f64; 2], end: [f64; 2]) { } // Like Photoshop: the placeholder is selected, so typing replaces it. let n = text.chars().count(); + app.ui.caret_blink_time = app.last_frame_time; app.ui.text_edit = Some(TextEdit { layer: id, caret: n, anchor: 0, session: key, created: true, mask, dragging: false, resize: None, preedit: None }); app.ui.text_edit_return_tool = None; } @@ -789,7 +791,11 @@ pub fn handle_keys(app: &mut PhotocraftApp, ctx: &egui::Context) -> bool { e.caret = e.caret.min(n); e.anchor = e.anchor.min(n); } - ctx.request_repaint_after(std::time::Duration::from_millis(530)); // caret blink + let time = ctx.input(|i| i.time); + let elapsed = (time - app.ui.caret_blink_time).max(0.0); + let period = 0.530; + let remaining = period - (elapsed % period); + ctx.request_repaint_after(std::time::Duration::from_secs_f64(remaining.clamp(0.010, period))); let events = ctx.input(|i| i.events.clone()); let vertical_flow = is_vertical(app, id); let mut handled = vec![false; events.len()]; @@ -942,6 +948,7 @@ pub fn handle_keys(app: &mut PhotocraftApp, ctx: &egui::Context) -> bool { } } if handled.iter().any(|h| *h) { + app.ui.caret_blink_time = ctx.input(|i| i.time); let mut k = 0; ctx.input_mut(|i| { i.events.retain(|_| { @@ -1000,6 +1007,9 @@ pub fn commit_with(app: &mut PhotocraftApp, mods: egui::Modifiers) { /// Selection highlight, caret and text frame over the canvas. pub fn draw_overlay(app: &mut PhotocraftApp, painter: &egui::Painter, xf: &ViewXform) { let Some(ed) = app.ui.text_edit.clone() else { return }; + if ed.dragging { + app.ui.caret_blink_time = painter.ctx().input(|i| i.time); + } let id = LayerId(ed.layer); let Some((l, aff, text)) = layout(app, id) else { return }; if ed.mask { @@ -1083,9 +1093,10 @@ pub fn draw_overlay(app: &mut PhotocraftApp, painter: &egui::Painter, xf: &ViewX } } } else { - // Blinking caret (Photoshop's ~0.53 s rhythm), solid while dragging. + // Blinking caret (Photoshop's ~0.53 s rhythm), solid while dragging or immediately after interaction. let time = painter.ctx().input(|i| i.time); - if ed.dragging || (time * 1000.0 / 530.0) as i64 % 2 == 0 { + let elapsed = (time - app.ui.caret_blink_time).max(0.0); + if ed.dragging || (elapsed * 1000.0 / 530.0) as i64 % 2 == 0 { let (x, top, bot) = l.caret(a); let (x, top, bot) = if l.lines.is_empty() { (0.0, -(12.0 * l.px_per_pt.max(1.0)), 3.0) } else { (x, top, bot) }; let c = if crate::theme::Tokens::get(painter.ctx()).pro { Color32::WHITE } else { Color32::BLACK }; @@ -2700,6 +2711,51 @@ mod tests { assert_eq!(word_boundary("hello big world", 7, false), 6); assert_eq!(word_boundary("hello big world", 15, false), 10); } + #[test] + fn arrow_keys_reset_caret_blink_phase() { + let mut app = app(); + pointer_up(&mut app, [10.0, 50.0], [10.0, 50.0]); + insert(&mut app, "Hello"); + app.ui.caret_blink_time = 0.0; + let ctx = egui::Context::default(); + + let has_caret = |app: &mut PhotocraftApp, ctx: &egui::Context, time: f64| -> bool { + let mut out = ctx.run_ui(egui::RawInput { time: Some(time), ..Default::default() }, |ui| { + let rect = egui::Rect::from_min_size(egui::Pos2::ZERO, egui::vec2(400.0, 400.0)); + let painter = ui.ctx().layer_painter(egui::LayerId::background()).with_clip_rect(rect); + let xf = ViewXform { rect, zoom: 1.0, center: [100.0, 100.0], flip: false, rotation: 0.0, aspect: 1.0 }; + draw_overlay(app, &painter, &xf); + }); + out.textures_delta.clear(); + out.shapes.iter().any(|s| matches!(&s.shape, egui::Shape::LineSegment { stroke, .. } if (stroke.width - 1.5).abs() < 1e-4)) + }; + + // Before resetting, the caret is in the off phase at t = 10.5 s (10500 / 530 = 19, odd phase) + assert!(!has_caret(&mut app, &ctx, 10.5), "caret should be off at t = 10.5 with phase 0.0"); + + ctx.begin_pass(egui::RawInput { + time: Some(10.5), + events: vec![egui::Event::Key { key: egui::Key::ArrowRight, physical_key: None, pressed: true, repeat: false, modifiers: egui::Modifiers::NONE }], + ..Default::default() + }); + handle_keys(&mut app, &ctx); + assert_eq!(app.ui.caret_blink_time, 10.5); + + // draw_overlay paints the solid caret in the new phase + assert!(has_caret(&mut app, &ctx, 10.5), "caret should be painted right after arrow key"); + } + + #[test] + fn pointer_up_click_resets_caret_blink_phase() { + let mut app = app(); + pointer_up(&mut app, [10.0, 50.0], [10.0, 50.0]); + insert(&mut app, "Hello"); + app.ui.caret_blink_time = 0.0; + app.last_frame_time = 12.3; + pointer_up(&mut app, [10.0, 50.0], [10.0, 50.0]); + assert_eq!(app.ui.caret_blink_time, 12.3); + } + #[test] fn double_click_from_move_tool_restores_move_tool_on_commit_and_cancel() { let (mut app, _) = variants(); From dd69f0797dd2c0dc82a206f5e7eddd21b56ff048 Mon Sep 17 00:00:00 2001 From: wwew <145259726+wwewtech@users.noreply.github.com> Date: Sat, 17 Oct 2026 04:23:08 +0400 Subject: [PATCH 48/85] fix(engine): reserve the define-brush tip and selection-mask buffers fallibly (#212) (#5469) brush.defineFromSelection allocated the sampled tip and the selection mask infallibly over the whole selection, so a large selection could abort the process. Both now go through the engine's fallible allocator and return a normal error that leaves the brush, presets, history and document untouched. A failpoint test asserts all four. Part of #212. --- crates/engine/src/brush_cmds.rs | 8 ++++++-- crates/engine/src/brush_cmds/tests.rs | 20 ++++++++++++++++++++ 2 files changed, 26 insertions(+), 2 deletions(-) diff --git a/crates/engine/src/brush_cmds.rs b/crates/engine/src/brush_cmds.rs index b76f8c836d..f2c9e28b2c 100644 --- a/crates/engine/src/brush_cmds.rs +++ b/crates/engine/src/brush_cmds.rs @@ -863,9 +863,13 @@ fn define_from_selection(s: &mut Session, p: &Value) -> Result { } // Paint amount = darkness × alpha × selection (black = full paint, like Photoshop). let (w, h) = (area.width() as usize, area.height() as usize); - let mut v = vec![0.0f32; w * h]; + // The sampled tip is one f32 buffer over the whole selection; a large selection must fail + // gracefully instead of aborting the process on allocation, so reserve it fallibly. + let mut v = crate::allocation::filled(w * h, 0.0, "defining a brush from the selection")?; let sc = sel.channels(); - let sv = sel.read_region(area); + // The selection mask is a second buffer of the same size; it must fail gracefully too + // (review of #5469), so read it through the fallible allocator. + let sv = crate::allocation::read_region(sel, area, "defining a brush from the selection")?; for y in 0..h { for x in 0..w { let c = surf.rgba(area.x0 + x as i32, area.y0 + y as i32); diff --git a/crates/engine/src/brush_cmds/tests.rs b/crates/engine/src/brush_cmds/tests.rs index eaf69281bc..5977603f94 100644 --- a/crates/engine/src/brush_cmds/tests.rs +++ b/crates/engine/src/brush_cmds/tests.rs @@ -442,6 +442,26 @@ fn define_brush_from_selection() { assert!(rgba(&s, 30, 32)[3] > 0.9); } +#[test] +fn define_brush_from_selection_survives_allocation_failure() { + // The sampled tip is one f32 buffer over the whole selection, and the selection mask is a + // second of the same size. Both are reserved through the fallible allocator, so an allocation + // failure must be a graceful error (brush, presets, history and document untouched), never a + // panic or an OOM abort. + let mut s = session(40, 40); + s.execute("select.rect", json!({"x": 5, "y": 5, "width": 20, "height": 20})).unwrap(); + let brush_before = s.tools.brush.clone(); + let presets_before = s.tools.presets.clone(); + let history_before = s.active().unwrap().history.past_len(); + crate::allocation::fail_next_for_test(); + let result = s.execute("brush.defineFromSelection", json!({"name": "Blob"})); + let _ = crate::allocation::capacity::(0, "reset failpoint"); + assert!(result.is_err(), "an allocation failure must be reported, not panic"); + assert_eq!(s.tools.brush, brush_before, "the current brush is unchanged"); + assert_eq!(s.tools.presets, presets_before, "the preset list is unchanged"); + assert_eq!(s.active().unwrap().history.past_len(), history_before, "no history step was recorded"); +} + #[test] fn works_on_cmyk_and_16_bit() { for (mode, depth) in [("cmyk", 8), ("rgb", 16), ("grayscale", 32)] { From 8a4ed48354fb893034938e299b9ec61db20d09f9 Mon Sep 17 00:00:00 2001 From: Harrison O'Connor-Hoover <130776285+harrisonoconnorhover@users.noreply.github.com> Date: Fri, 16 Oct 2026 20:25:50 -0400 Subject: [PATCH 49/85] fix(ui): numeric fields drop oversized text, paste and IME events before layout (part of #2150) (#2258) A single oversized input event reached egui text layout in a numeric field even though it could never become a valid value. number_edit now drops Text, Paste and IME events longer than 128 scalar values while the field has focus, and the custom parser rejects accumulated text over the cap before normalizing. DragValue keeps focus, accessibility, drag and arithmetic behaviour. Six harness tests cover the event kinds, the Unicode boundary, accumulation, undo, and document text fields staying unrestricted. Part of #2150. --- crates/ui-egui/src/widgets.rs | 29 +++ .../src/widgets/numeric_input_tests.rs | 195 ++++++++++++++++++ 2 files changed, 224 insertions(+) create mode 100644 crates/ui-egui/src/widgets/numeric_input_tests.rs diff --git a/crates/ui-egui/src/widgets.rs b/crates/ui-egui/src/widgets.rs index 48d7cc5ad0..533f1ee052 100644 --- a/crates/ui-egui/src/widgets.rs +++ b/crates/ui-egui/src/widgets.rs @@ -793,6 +793,8 @@ pub fn focus_first_field(ctx: &egui::Context, on: bool) { }); } +const NUMERIC_INPUT_LIMIT: usize = 128; + /// The number in a [`value_field`]. A typed number applies as it's typed; arithmetic waits for /// Enter, Tab or click-away, because per keystroke `5/2` would land first and a caller that /// rounds it would cut `5/2*2` short. `changed()` means a new value, not just a keystroke. @@ -814,6 +816,25 @@ fn number_edit( if ui.is_enabled() { crate::field_tab::register(&ctx, id); } + // Use DragValue's focus check before it builds its TextEdit: oversized events must never + // reach text layout. Registering interest also handles focus arriving by Tab this frame. + let editing = ui.is_enabled() + && ui.memory_mut(|m| { + m.interested_in_focus(id, ui.layer_id()); + m.has_focus(id) + }); + if editing { + ui.input_mut(|input| { + input.events.retain(|event| { + let text = match event { + egui::Event::Text(text) | egui::Event::Paste(text) | egui::Event::Ime(egui::ImeEvent::Commit(text)) => text, + egui::Event::Ime(egui::ImeEvent::Preedit { text, .. }) => text, + _ => return true, + }; + text.chars().nth(NUMERIC_INPUT_LIMIT).is_none() + }); + }); + } let held = id.with("arithmetic"); let math = ui.memory(|m| m.has_focus(id)) && ui.data(|d| d.get_temp(held)).unwrap_or(false); ui.data_mut(|d| d.insert_temp(held, math)); @@ -837,6 +858,10 @@ fn number_edit( .update_while_editing(!math) // Focus is read when parsing, not above: Tab hands it on inside `ui.add`. .custom_parser(move |s| { + // Short events can accumulate: reject before either parser normalizes the text. + if s.chars().nth(NUMERIC_INPUT_LIMIT).is_some() { + return None; + } let v = parse_num_in(s, units); if ctx.memory(|m| m.has_focus(id)) && plain(s).is_none() { // Still typing arithmetic: hold it, and stop applying keystrokes once it parses. @@ -2654,3 +2679,7 @@ mod screen_color_tests { } } } + +#[cfg(test)] +#[path = "widgets/numeric_input_tests.rs"] +mod numeric_input_tests; diff --git a/crates/ui-egui/src/widgets/numeric_input_tests.rs b/crates/ui-egui/src/widgets/numeric_input_tests.rs new file mode 100644 index 0000000000..41eff2ed4c --- /dev/null +++ b/crates/ui-egui/src/widgets/numeric_input_tests.rs @@ -0,0 +1,195 @@ +//! Reject oversized numeric events before layout while preserving DragValue behavior. + +use egui::{Event, ImeEvent, Key, Modifiers, accesskit::Role}; +use egui_kittest::{Harness, kittest::Queryable}; + +const LIMIT: usize = super::NUMERIC_INPUT_LIMIT; +type Fields = (f32, String); + +fn fields() -> Harness<'static, Fields> { + let mut h = Harness::builder().with_size(egui::vec2(400.0, 180.0)).build_ui_state( + |ui, (number, text): &mut Fields| { + super::value_field(ui, number, 0.01..=12800.0, "%", 90.0); + ui.add(egui::TextEdit::multiline(text).hint_text("Document text")); + }, + (100.0, String::new()), + ); + h.run(); + h +} + +fn focused() -> Harness<'static, Fields> { + let mut h = fields(); + h.get_by_role(Role::SpinButton).click(); + h.run(); + h +} + +fn numeric_text(h: &Harness<'_, Fields>) -> String { + h.query_by_role(Role::TextInput).or_else(|| h.query_by_role(Role::SpinButton)).expect("numeric editor").value().expect("numeric text") +} + +fn select_all(h: &mut Harness<'_, Fields>) { + h.key_press_modifiers(Modifiers::COMMAND, Key::A); + h.run(); +} + +fn assert_bounded(h: &Harness<'_, Fields>) { + fn longest(shape: &egui::Shape) -> usize { + match shape { + egui::Shape::Text(text) => text.galley.job.text.chars().count(), + egui::Shape::Vec(shapes) => shapes.iter().map(longest).max().unwrap_or(0), + _ => 0, + } + } + let drawn = h.output().shapes.iter().map(|shape| longest(&shape.shape)).max().unwrap_or(0); + assert!(drawn <= LIMIT, "numeric input laid out {drawn} characters; expected at most {LIMIT}"); + let retained = numeric_text(h).chars().count(); + assert!(retained <= LIMIT, "numeric editor retained {retained} characters; expected at most {LIMIT}"); +} + +#[test] +fn oversized_numeric_text_paste_and_ime_are_bounded_before_layout() { + for (index, event) in [ + Event::Text("9".repeat(16 * 1024)), + Event::Paste("x".repeat(16 * 1024)), + Event::Ime(ImeEvent::Preedit { text: "é".repeat(8 * 1024), active_range_chars: None }), + Event::Ime(ImeEvent::Commit("é".repeat(8 * 1024))), + ] + .into_iter() + .enumerate() + { + let mut h = focused(); + select_all(&mut h); + if matches!(&event, Event::Ime(_)) { + // egui 0.36 assigns IME events to the focused widget; Enabled is deprecated and + // ignored. Prove both live composition and commitment reach this field first. + h.event(Event::Ime(ImeEvent::Preedit { text: "に".into(), active_range_chars: Some(0..1) })); + h.run(); + assert_eq!(numeric_text(&h), "に", "short IME composition reaches the numeric editor"); + h.event(Event::Ime(ImeEvent::Commit("é".into()))); + h.run(); + assert_eq!(numeric_text(&h), "é", "IME commit replaces its composition"); + select_all(&mut h); + } + h.event(event); + h.run(); + if let Some(dir) = std::env::var_os("CRAFT_NUMERIC_INPUT_FIXTURES") { + let dir = std::path::PathBuf::from(dir); + std::fs::create_dir_all(&dir).expect("create numeric fixture directory"); + h.render().expect("render numeric fixture").save(dir.join(format!("event-{index}.png"))).expect("save numeric fixture"); + } + assert_bounded(&h); + assert_eq!(h.state().0, 100.0, "invalid input leaves the numeric value unchanged"); + h.run(); + assert_bounded(&h); + } +} + +#[test] +fn numeric_replacement_clear_and_undo_preserve_unicode_input() { + let mut h = focused(); + select_all(&mut h); + for _ in 0..4 { + h.event(Event::Text("é".repeat(16))); + h.run(); + assert_bounded(&h); + } + let retained = numeric_text(&h); + assert!(!retained.is_empty(), "ordinary short inputs still enter the editor"); + // Let egui's text undoer store the stable text before clearing it. + h.input_mut().time = Some(h.ctx.input(|i| i.time) + 2.0); + h.run(); + select_all(&mut h); + h.key_press(Key::Backspace); + h.run(); + assert_eq!(numeric_text(&h), ""); + h.key_press_modifiers(Modifiers::COMMAND, Key::Z); + h.run(); + assert_eq!(numeric_text(&h), retained, "undo restores only the bounded input"); + assert_bounded(&h); + select_all(&mut h); + h.event(Event::Text("−4+6.5".into())); + h.run(); + assert_eq!(numeric_text(&h), "−4+6.5", "replacement accepts Unicode arithmetic"); + h.key_press(Key::Enter); + h.run(); + assert_eq!(h.state().0, 2.5); +} + +#[test] +fn numeric_input_limit_does_not_truncate_document_text() { + for switch_from_numeric in [false, true] { + let mut h = if switch_from_numeric { focused() } else { fields() }; + let text = "é".repeat(8 * 1024); + h.get_by_role(Role::MultilineTextInput).click(); + h.run(); + h.event(Event::Paste(text.clone())); + h.run(); + assert_eq!(h.state().1, text, "document paste preserved when switching from numeric: {switch_from_numeric}"); + assert_eq!(h.get_by_role(Role::MultilineTextInput).value().as_deref(), Some(text.as_str())); + assert_eq!(h.state().0, 100.0); + } +} + +#[test] +fn numeric_event_limit_counts_unicode_characters_and_rejects_without_truncating() { + let mut h = focused(); + select_all(&mut h); + let boundary = "é".repeat(LIMIT); + h.event(Event::Paste(boundary.clone())); + h.run(); + assert_eq!(numeric_text(&h), boundary); + select_all(&mut h); + h.event(Event::Text("9".repeat(LIMIT + 1))); + h.run(); + assert_eq!(numeric_text(&h), boundary, "oversized input leaves the selection intact"); + h.event(Event::Text("42".into())); + h.run(); + assert_eq!(h.state().0, 42.0); +} + +#[test] +fn numeric_parser_rejects_oversized_accumulated_text_before_normalizing() { + let mut h = focused(); + select_all(&mut h); + // Each event is under the cap, but the resulting input is too long. Normalizing first + // would hide all the spaces and apply 2. DragValue still owns its editable text. + h.event(Event::Text(" ".repeat(LIMIT / 2))); + h.run(); + h.event(Event::Text(format!("{}2", " ".repeat(LIMIT / 2)))); + h.run(); + assert_eq!(numeric_text(&h).chars().count(), LIMIT + 1); + assert_eq!(h.state().0, 100.0, "oversized plain input must not apply live"); + h.key_press(Key::Enter); + h.run(); + assert_eq!(h.state().0, 100.0, "oversized input must not apply on commit"); +} + +#[test] +fn tab_and_paste_in_one_frame_filter_only_the_receiving_numeric_field() { + let mut h = focused(); + let text = "x".repeat(16 * 1024); + h.key_press(Key::Tab); + h.event(Event::Paste(text.clone())); + h.run(); + assert_eq!(h.state().1, text, "Tab to document text preserves the paste"); + // Put the numeric field after a text field to exercise incoming forward Tab too. + // egui deliberately delays backwards traversal until the next frame. + let mut h = Harness::builder().with_size(egui::vec2(400.0, 180.0)).build_ui_state( + |ui, (number, text): &mut Fields| { + ui.add(egui::TextEdit::multiline(text).hint_text("Document text")); + super::value_field(ui, number, 0.01..=12800.0, "%", 90.0); + }, + (100.0, String::new()), + ); + h.run(); + h.get_by_role(Role::MultilineTextInput).click(); + h.run(); + h.key_press(Key::Tab); + h.event(Event::Paste("9".repeat(16 * 1024))); + h.run(); + assert_eq!(h.state().0, 100.0); + assert!(h.state().1.is_empty()); + assert_bounded(&h); +} From 1b3bc48267700b299437ac4ea8fdc3f404a83f09 Mon Sep 17 00:00:00 2001 From: Gert Pellin - GPE Date: Sat, 17 Oct 2026 02:28:51 +0200 Subject: [PATCH 50/85] docs(readme): Community packages section with the Gentoo ::snakebyte overlay (#1149) Adds a Community packages heading to the README, noting these installers are maintained by community members, with the Gentoo recipe for media-gfx/photocraft-bin from the ::snakebyte overlay. The overlay's ebuild fetches the official Linux x86_64 release tarball with Manifest-pinned hashes that match the release's SHA256SUMS. --- README.md | 13 +++++++++++++ 1 file changed, 13 insertions(+) diff --git a/README.md b/README.md index d1e839323c..2ab84f644b 100644 --- a/README.md +++ b/README.md @@ -295,6 +295,19 @@ tar -xzf photocraft--freebsd-x86_64.tar.gz --strip-components 1 -C /usr photocraft ``` +## Community packages + +These installers are maintained by community members, not by the PhotoCraft team. + +On Gentoo, the [::snakebyte overlay](https://github.com/switch87/snakebyte-overlay) packages the Linux release as `media-gfx/photocraft-bin`: + +```sh +eselect repository add snakebyte git https://github.com/switch87/snakebyte-overlay.git +emaint sync -r snakebyte +echo 'media-gfx/photocraft-bin ~amd64' >> /etc/portage/package.accept_keywords/photocraft +emerge --ask media-gfx/photocraft-bin +``` + Maintainers: [`docs/releasing.md`](docs/releasing.md) explains how releases are built, signed and published. > [!IMPORTANT] From a6a1d2ed2221e3bfa82d055b35d93b482fae22d8 Mon Sep 17 00:00:00 2001 From: Gert Pellin - GPE Date: Sat, 17 Oct 2026 02:31:02 +0200 Subject: [PATCH 51/85] feat(algo): face detection with libfacedetection's CNN in pure Rust, and image.detectFaces (part of #3603) (#4513) photocraft_algo::faces::detect finds faces in an RGBA image with a score, a box and five landmarks, through a pure-Rust port of libfacedetection's YuNet network (Shiqi Yu, BSD-3-Clause; licence, NOTICE and ATTRIBUTION rows included). The weights are the library's, converted to half precision (107 KB), parsed defensively. image.detectFaces reports the document's faces to scripts and agents; it is read-only with no history step and no menu entry. The network is compiled out of the browser build, where the command reports itself unavailable. Head outputs are pinned against the C++ reference. Part of #3603. --- ATTRIBUTION.md | 1 + NOTICE | 1 + crates/algo/data/LICENSE-libfacedetection.txt | 35 ++ crates/algo/data/libfacedetection.bin | Bin 0 -> 106643 bytes crates/algo/src/faces.rs | 90 +++++ crates/algo/src/faces/cnn.rs | 323 ++++++++++++++++++ crates/algo/src/lib.rs | 1 + crates/engine/src/commands.rs | 1 + crates/engine/src/face_cmds.rs | 115 +++++++ crates/engine/src/lib.rs | 1 + 10 files changed, 568 insertions(+) create mode 100644 crates/algo/data/LICENSE-libfacedetection.txt create mode 100644 crates/algo/data/libfacedetection.bin create mode 100644 crates/algo/src/faces.rs create mode 100644 crates/algo/src/faces/cnn.rs create mode 100644 crates/engine/src/face_cmds.rs diff --git a/ATTRIBUTION.md b/ATTRIBUTION.md index 26eca62464..1ae19efa66 100644 --- a/ATTRIBUTION.md +++ b/ATTRIBUTION.md @@ -57,6 +57,7 @@ next to them. | `crates/ui-egui/src/theme.rs` (Solarized Dark palette) | Solarized colour scheme used by the Solarized Dark theme | Ethan Schoonover | | MIT, [`LICENSE-solarized.txt`](crates/ui-egui/src/LICENSE-solarized.txt) | | `crates/ui-egui/src/theme.rs` (Breeze Light and Breeze Dark themes) | The Breeze Light and Breeze Dark themes are original colour values on PhotoCraft's Studio layout, chosen to look close to KDE Plasma's Breeze (cool blue-greys, blue accent, 5 px frame radius) so the app blends into a Plasma desktop. None of Breeze's colour values, files or code are copied; the values are a few levels away from its schemes and tuned for text contrast | PhotoCraft contributors | Inspired by the look of KDE Breeze, (its colour schemes are LGPL-2.0-or-later) | Original work | | `crates/ui-egui/src/theme.rs` (Soft Dark and Soft Light themes) | Original colour values and radii for two low-contrast themes on PhotoCraft's Studio layout; no third-party palette, file or code is used | PhotoCraft contributors | Original | Original work | +| `crates/algo/data/libfacedetection.bin` (face detection weights, read by `crates/algo/src/faces.rs`, whose network code is a port of the library's) | libfacedetection CNN (YuNet design) weights and inference code, weights converted to half precision | Shiqi Yu | [ShiqiYu/libfacedetection](https://github.com/ShiqiYu/libfacedetection) (`src/facedetectcnn-data.cpp`, `facedetectcnn.cpp`, `facedetectcnn-model.cpp`) | BSD-3-Clause, [`LICENSE-libfacedetection.txt`](crates/algo/data/LICENSE-libfacedetection.txt) | ### Optional build input: craft-fonts (not files in this repo) diff --git a/NOTICE b/NOTICE index 887010d4a6..a7302a940c 100644 --- a/NOTICE +++ b/NOTICE @@ -16,6 +16,7 @@ Inter font (assets/fonts/Inter-*.ttf) — SIL Open Font License 1.1. See assets/ JetBrains Mono font (assets/fonts/JetBrainsMono-Regular.ttf) — SIL Open Font License 1.1. See assets/fonts/OFL-JetBrainsMono.txt. Lucide icons (assets/icons/*.svg, except slice-knife.svg) — ISC; icons derived from Feather also MIT. See assets/icons/LICENSE-lucide.txt. SCOWL English word list (assets/dict/en_US-scowl-50.txt.gz) — SCOWL license. See assets/dict/LICENSE-SCOWL.txt. +libfacedetection face detection weights and network (crates/algo/data/libfacedetection.bin, crates/algo/src/faces/cnn.rs) — BSD-3-Clause, Copyright (c) 2015-2019, Shiqi Yu, all rights reserved. See crates/algo/data/LICENSE-libfacedetection.txt. Builds made with craft-fonts (CRAFT_FONTS_DIR; all official releases) also embed the fonts listed in https://github.com/storytold/craft-fonts/blob/main/ATTRIBUTION.md (today BIZ UDPGothic, Shippori Mincho and diff --git a/crates/algo/data/LICENSE-libfacedetection.txt b/crates/algo/data/LICENSE-libfacedetection.txt new file mode 100644 index 0000000000..33abd64f06 --- /dev/null +++ b/crates/algo/data/LICENSE-libfacedetection.txt @@ -0,0 +1,35 @@ +By downloading, copying, installing or using the software you agree to this license. +If you do not agree to this license, do not download, install, +copy or use the software. + + + License Agreement For libfacedetection + (3-clause BSD License) + +Copyright (c) 2015-2019, Shiqi Yu, all rights reserved. +shiqi.yu@gmail.com + +Redistribution and use in source and binary forms, with or without modification, +are permitted provided that the following conditions are met: + + * Redistributions of source code must retain the above copyright notice, + this list of conditions and the following disclaimer. + + * Redistributions in binary form must reproduce the above copyright notice, + this list of conditions and the following disclaimer in the documentation + and/or other materials provided with the distribution. + + * Neither the names of the copyright holders nor the names of the contributors + may be used to endorse or promote products derived from this software + without specific prior written permission. + +This software is provided by the copyright holders and contributors "as is" and +any express or implied warranties, including, but not limited to, the implied +warranties of merchantability and fitness for a particular purpose are disclaimed. +In no event shall copyright holders or contributors be liable for any direct, +indirect, incidental, special, exemplary, or consequential damages +(including, but not limited to, procurement of substitute goods or services; +loss of use, data, or profits; or business interruption) however caused +and on any theory of liability, whether in contract, strict liability, +or tort (including negligence or otherwise) arising in any way out of +the use of this software, even if advised of the possibility of such damage. \ No newline at end of file diff --git a/crates/algo/data/libfacedetection.bin b/crates/algo/data/libfacedetection.bin new file mode 100644 index 0000000000000000000000000000000000000000..0941c4f689dae8012c98b16bbc530f2d59173a92 GIT binary patch literal 106643 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%0AH+ z^#ekmewvCHLavK=f_b!idWjMGZ62;z6wPda&fZz-MBoXz*s(Hs-CF;H_MGZM^IBRFIDph;n zXK-H0_WrSRga1~iQ+7C{vlT-Qc$A+tP}(zFh1r;@O%9Wv;C<0Qh3V|;6AT_vZu64Ijz+r9I>*;X z5e|I@@Kcs-#xI0^$@v@g5XU1&4dY^F=&+&;{G%z-V)uTQ&O>xp@#6i*5x)?ecHE}O z7yY-fM@W|Ht;~%eUm68E-}CK1^vI;+P82Xz@+T^i)%=`wC_};-Tg)sB9Ga^ojOp|)w zL{Km5#!&BGX|j8?d%nMlUBa&QG6ZSnDM7W^8~JC?YtU=Gpql0hz{bAG-h`%jjgoJJ z^XZ6quJ5k3&43qqf#_59@MJ}!0j^=JQk`|&IBn96)77$p}DO2JhCUU zcF?IQ9}Fs0;qBq;d9>=SfjOaIIH=w|a8r>R$_-pqoL3$V9S(JdQRVfJPV@P|(a4Fs zX02f0@L)ltNxL%gwR~mZm%tt6de!;hc(6XWMa>Kq(-f&w)H@R}rp)L{1~XQc>i+@U C?{l>P literal 0 HcmV?d00001 diff --git a/crates/algo/src/faces.rs b/crates/algo/src/faces.rs new file mode 100644 index 0000000000..aa246fcaf8 --- /dev/null +++ b/crates/algo/src/faces.rs @@ -0,0 +1,90 @@ +//! Face detection: a port of libfacedetection's CNN (Shiqi Yu, BSD-3; `data/LICENSE-libfacedetection.txt`). +//! +//! The network is the YuNet design: a backbone of 1×1 pointwise and 3×3 depthwise convolutions with +//! 2×2 max pooling, a feature pyramid at strides 8, 16 and 32, and per-cell heads for the face score, +//! the box and five landmarks (eyes, nose tip, mouth corners). The weights (`data/libfacedetection.bin`, +//! converted to half precision from the library's `facedetectcnn-data.cpp`) are about 100 KB, and +//! inference is plain Rust: no runtime, so it works offline. +//! +//! [`detect`] takes straight RGBA in `0..=1` and returns faces in pixel coordinates of that image. +//! +//! The browser build (wasm32) leaves the network and its weights out, as `photocraft-raw` does with +//! its JPEG XL decoder: they would push the wasm over the per-file cap of static hosts +//! (packaging/web/package.sh). There [`AVAILABLE`] is false and [`detect`] finds nothing. + +#[cfg(not(target_arch = "wasm32"))] +mod cnn; + +/// Whether this build can detect faces (not the browser build: see the module docs). +pub const AVAILABLE: bool = cfg!(not(target_arch = "wasm32")); + +/// Longest side the network looks at; larger images are scaled down first (faces smaller than +/// about 10 px at this scale are missed). +pub const MAX_SIDE: usize = 1280; + +/// One detected face, in pixels of the image passed to [`detect`]. +#[derive(Clone, Copy, Debug, PartialEq)] +pub struct Face { + /// Confidence `0..=1` (the library reports faces above 0.5 as reliable). + pub score: f32, + /// Left, top, width and height. + pub rect: [f32; 4], + /// Right eye, left eye (as seen in the image: the first is on the image's left), nose tip, + /// right mouth corner, left mouth corner. + pub landmarks: [[f32; 2]; 5], +} + +/// Faces in `px` (straight RGBA `0..=1`, `w × h`, row-major) scoring at least `min_score`, best +/// first. Transparent pixels count as white. `None` when `px` doesn't match the size. +#[cfg(not(target_arch = "wasm32"))] +pub fn detect(px: &[[f32; 4]], w: usize, h: usize, min_score: f32) -> Option> { + if w == 0 || h == 0 || px.len() != w.checked_mul(h)? { + return None; + } + // Scale down (box filter) so the longer side is at most MAX_SIDE. + let step = w.max(h).div_ceil(MAX_SIDE).max(1); + let (sw, sh) = (w.div_ceil(step), h.div_ceil(step)); + let mut bgr = vec![[0.0f32; 3]; sw * sh]; + for y in 0..sh { + for x in 0..sw { + let (mut acc, mut n) = ([0.0f32; 3], 0.0f32); + for yy in y * step..((y + 1) * step).min(h) { + for xx in x * step..((x + 1) * step).min(w) { + let [r, g, b, a] = px[yy * w + xx]; + let a = a.clamp(0.0, 1.0); + for (k, v) in [b, g, r].into_iter().enumerate() { + acc[k] += v.clamp(0.0, 1.0) * a + (1.0 - a); + } + n += 1.0; + } + } + bgr[y * sw + x] = acc.map(|v| (v / n.max(1.0) * 255.0).round()); + } + } + let s = step as f32; + let faces = cnn::network(&bgr, sw, sh)? + .into_iter() + .filter(|f| f.score >= min_score) + .map(|f| Face { score: f.score, rect: f.rect.map(|v| v * s), landmarks: f.landmarks.map(|[x, y]| [x * s, y * s]) }) + .collect(); + Some(faces) +} + +/// The browser build has no detector (see the module docs): always `None`. +#[cfg(target_arch = "wasm32")] +pub fn detect(_px: &[[f32; 4]], _w: usize, _h: usize, _min_score: f32) -> Option> { + None +} + +#[cfg(all(test, not(target_arch = "wasm32")))] +mod tests { + use super::*; + + #[test] + fn no_faces_in_flat_or_tiny_images_and_bad_sizes_are_refused() { + assert_eq!(detect(&vec![[0.5, 0.5, 0.5, 1.0]; 64 * 48], 64, 48, 0.5), Some(vec![])); + assert_eq!(detect(&[[1.0, 1.0, 1.0, 1.0]], 1, 1, 0.5), Some(vec![])); + assert_eq!(detect(&[[1.0; 4]; 3], 2, 2, 0.5), None); + assert_eq!(detect(&[], 0, 0, 0.5), None); + } +} diff --git a/crates/algo/src/faces/cnn.rs b/crates/algo/src/faces/cnn.rs new file mode 100644 index 0000000000..b86cac7eb2 --- /dev/null +++ b/crates/algo/src/faces/cnn.rs @@ -0,0 +1,323 @@ +//! The network itself (native builds only: see the parent module's docs). + +use super::Face; +use crate::par; + +/// The weights: `PCFACE1\0`, the layer count (u16), then per layer its input channels (u16), filters +/// (u16) and kind (u8: 0 pointwise, 1 depthwise), the weights and the biases, all little-endian f16. +static WEIGHTS: &[u8] = include_bytes!("../../data/libfacedetection.bin"); + +const MAGIC: &[u8] = b"PCFACE1\0"; +const LAYERS: usize = 53; +/// Faces whose boxes overlap more than this (intersection over union) are one face. +const NMS_OVERLAP: f32 = 0.45; +/// Candidates kept before non-maximum suppression, as the library does. +const TOP_K: usize = 1000; + +struct Layer { + channels: usize, + filters: usize, + depthwise: bool, + weights: Vec, + biases: Vec, +} + +/// A feature map: `rows × cols` cells of `channels` values each. +#[derive(Clone)] +struct Blob { + rows: usize, + cols: usize, + channels: usize, + data: Vec, +} + +impl Blob { + fn new(rows: usize, cols: usize, channels: usize) -> Self { + Blob { rows, cols, channels, data: vec![0.0; rows * cols * channels] } + } +} + +fn f16_to_f32(h: u16) -> f32 { + let sign = if h & 0x8000 != 0 { -1.0 } else { 1.0 }; + let exp = i32::from((h >> 10) & 0x1f); + let frac = f32::from(h & 0x3ff); + match exp { + 0 => sign * frac * (2.0f32).powi(-24), + 31 => { + if frac == 0.0 { + sign * f32::INFINITY + } else { + f32::NAN + } + } + _ => sign * (1.0 + frac / 1024.0) * (2.0f32).powi(exp - 15), + } +} + +fn parse(bytes: &[u8]) -> Option> { + let rest = bytes.strip_prefix(MAGIC)?; + let mut at = 0usize; + let u16_at = |n: &mut usize| -> Option { + let b = rest.get(*n..*n + 2)?; + *n += 2; + Some(u16::from_le_bytes([b[0], b[1]])) + }; + let count = usize::from(u16_at(&mut at)?); + if count != LAYERS { + return None; + } + let mut layers = Vec::with_capacity(count); + for _ in 0..count { + let channels = usize::from(u16_at(&mut at)?); + let filters = usize::from(u16_at(&mut at)?); + let depthwise = *rest.get(at)? == 1; + at += 1; + let nw = if depthwise { 9 * channels } else { filters * channels }; + let mut read = |n: usize| -> Option> { (0..n).map(|_| u16_at(&mut at).map(f16_to_f32)).collect() }; + let weights = read(nw)?; + let biases = read(filters)?; + if depthwise && filters != channels { + return None; + } + layers.push(Layer { channels, filters, depthwise, weights, biases }); + } + (at == rest.len()).then_some(layers) +} + +/// A 1×1 or 3×3 depthwise convolution with bias, then ReLU when `relu`. +fn conv(x: &Blob, l: &Layer, relu: bool) -> Option { + if x.channels != l.channels { + return None; + } + let mut out = Blob::new(x.rows, x.cols, l.filters); + let (rows, cols, cin, cout) = (x.rows, x.cols, x.channels, l.filters); + par::chunks_mut(&mut out.data, cols * cout, rows * cols * cout > 1 << 16, |row, line| { + for col in 0..cols { + let o = &mut line[col * cout..(col + 1) * cout]; + if l.depthwise { + o.copy_from_slice(&l.biases); + for r in row.saturating_sub(1)..(row + 2).min(rows) { + for c in col.saturating_sub(1)..(col + 2).min(cols) { + let k = (r + 1 - row) * 3 + (c + 1 - col); + let input = &x.data[(r * cols + c) * cin..(r * cols + c + 1) * cin]; + let w = &l.weights[k * cin..(k + 1) * cin]; + for ((o, i), w) in o.iter_mut().zip(input).zip(w) { + *o += i * w; + } + } + } + } else { + let input = &x.data[(row * cols + col) * cin..(row * cols + col + 1) * cin]; + for (f, o) in o.iter_mut().enumerate() { + let w = &l.weights[f * cin..(f + 1) * cin]; + *o = l.biases[f] + input.iter().zip(w).map(|(i, w)| i * w).sum::(); + } + } + if relu { + for v in o.iter_mut() { + *v = v.max(0.0); + } + } + } + }); + Some(out) +} + +/// Pointwise then depthwise (ReLU after the depthwise one when `relu`). +fn conv_dp(x: &Blob, p: &Layer, d: &Layer, relu: bool) -> Option { + conv(&conv(x, p, false)?, d, relu) +} + +fn unit(x: &Blob, l: &[Layer]) -> Option { + let [p1, d1, p2, d2] = l else { return None }; + conv_dp(&conv_dp(x, p1, d1, true)?, p2, d2, true) +} + +fn max_pool(x: &Blob) -> Blob { + let rows = x.rows.saturating_sub(3).div_ceil(2) + 1; + let cols = x.cols.saturating_sub(3).div_ceil(2) + 1; + let mut out = Blob::new(rows, cols, x.channels); + for r in 0..rows { + for c in 0..cols { + let o = (r * cols + c) * x.channels; + for ch in 0..x.channels { + let mut m = f32::NEG_INFINITY; + for rr in r * 2..(r * 2 + 2).min(x.rows) { + for cc in c * 2..(c * 2 + 2).min(x.cols) { + m = m.max(x.data[(rr * x.cols + cc) * x.channels + ch]); + } + } + out.data[o + ch] = m; + } + } + } + out +} + +/// `small` upsampled ×2 (nearest) plus `big`. +fn up_add(small: &Blob, big: &Blob) -> Option { + if small.rows * 2 != big.rows || small.cols * 2 != big.cols || small.channels != big.channels { + return None; + } + let mut out = big.clone(); + let ch = big.channels; + for r in 0..big.rows { + for c in 0..big.cols { + let s = ((r / 2) * small.cols + c / 2) * ch; + let o = (r * big.cols + c) * ch; + for k in 0..ch { + out.data[o + k] += small.data[s + k]; + } + } + } + Some(out) +} + +/// The network's input: each cell holds the 3×3 neighbourhood (stride 2) of BGR values `0..=255`, +/// so the first, 3×3 stride-2 convolution is a pointwise one. The size is padded to 32. +fn input(bgr: &[[f32; 3]], w: usize, h: usize) -> Blob { + let rows = h.div_ceil(32) * 16; + let cols = w.div_ceil(32) * 16; + let mut b = Blob::new(rows, cols, 32); + for r in 0..rows { + for c in 0..cols { + let cell = (r * cols + c) * 32; + for fy in 0..3 { + let Some(y) = (r * 2 + fy).checked_sub(1).filter(|&y| y < h) else { continue }; + for fx in 0..3 { + let Some(x) = (c * 2 + fx).checked_sub(1).filter(|&x| x < w) else { continue }; + let o = cell + (fy * 3 + fx) * 3; + b.data[o..o + 3].copy_from_slice(&bgr[y * w + x]); + } + } + } + } + b +} + +/// The heads of one pyramid level, decoded to candidate faces (in network pixels). +fn candidates(feat: &Blob, l: &[Layer], heads: [usize; 4], stride: f32, out: &mut Vec) -> Option<()> { + let head = |i: usize| conv_dp(feat, l.get(i)?, l.get(i + 1)?, false); + let (cls, reg, obj, kps) = (head(heads[0])?, head(heads[1])?, head(heads[2])?, head(heads[3])?); + if cls.channels != 1 || obj.channels != 1 || reg.channels != 4 || kps.channels != 10 { + return None; + } + let sigmoid = |v: f32| 1.0 / (1.0 + (-v.clamp(-88.0, 88.0)).exp()); + for r in 0..feat.rows { + for c in 0..feat.cols { + let i = r * feat.cols + c; + let score = (sigmoid(cls.data[i]) * sigmoid(obj.data[i])).sqrt(); + if score < 0.2 { + continue; + } + let (px, py) = (c as f32 * stride, r as f32 * stride); + let b = ®.data[i * 4..i * 4 + 4]; + let (cx, cy) = (b[0] * stride + px, b[1] * stride + py); + let (bw, bh) = (b[2].exp() * stride, b[3].exp() * stride); + let k = &kps.data[i * 10..i * 10 + 10]; + let mut landmarks = [[0.0; 2]; 5]; + for (n, lm) in landmarks.iter_mut().enumerate() { + *lm = [k[2 * n] * stride + px, k[2 * n + 1] * stride + py]; + } + out.push(Face { score, rect: [cx - bw / 2.0, cy - bh / 2.0, bw, bh], landmarks }); + } + } + Some(()) +} + +fn iou(a: &[f32; 4], b: &[f32; 4]) -> f32 { + let w = ((a[0] + a[2]).min(b[0] + b[2]) - a[0].max(b[0])).max(0.0); + let h = ((a[1] + a[3]).min(b[1] + b[3]) - a[1].max(b[1])).max(0.0); + let i = w * h; + if i <= 0.0 { 0.0 } else { i / (a[2] * a[3] + b[2] * b[3] - i) } +} + +/// The heads (cls, reg, obj, kps: their first layer) of the stride 8, 16 and 32 levels. +const HEADS: [[usize; 4]; 3] = [[29, 35, 41, 47], [31, 37, 43, 49], [33, 39, 45, 51]]; + +/// The backbone and feature pyramid: the stride 8, 16 and 32 feature maps the heads read. +fn features(bgr: &[[f32; 3]], w: usize, h: usize, l: &[Layer]) -> Option<[Blob; 3]> { + let x = conv(&input(bgr, w, h), l.first()?, true)?; + let x = max_pool(&conv_dp(&x, l.get(1)?, l.get(2)?, true)?); + let x = unit(&unit(&x, l.get(3..7)?)?, l.get(7..11)?)?; + let f8 = unit(&max_pool(&x), l.get(11..15)?)?; + let f16 = unit(&max_pool(&f8), l.get(15..19)?)?; + let f32_ = unit(&max_pool(&f16), l.get(19..23)?)?; + let f32_ = conv_dp(&f32_, l.get(27)?, l.get(28)?, true)?; + let f16 = conv_dp(&up_add(&f32_, &f16)?, l.get(25)?, l.get(26)?, true)?; + let f8 = conv_dp(&up_add(&f16, &f8)?, l.get(23)?, l.get(24)?, true)?; + Some([f8, f16, f32_]) +} + +pub(super) fn network(bgr: &[[f32; 3]], w: usize, h: usize) -> Option> { + let l = parse(WEIGHTS)?; + let levels = features(bgr, w, h, &l)?; + let mut faces = Vec::new(); + // Heads (cls, reg, obj, kps) per level, as in the library's model. + for ((feat, heads), stride) in levels.iter().zip(HEADS).zip([8.0, 16.0, 32.0]) { + candidates(feat, &l, heads, stride, &mut faces)?; + } + faces.sort_by(|a, b| b.score.total_cmp(&a.score)); + faces.truncate(TOP_K); + let mut kept: Vec = Vec::new(); + for f in faces { + if kept.iter().all(|k| iou(&k.rect, &f.rect) <= NMS_OVERLAP) { + kept.push(f); + } + } + Some(kept) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn weights_parse() { + let l = parse(WEIGHTS).expect("bundled weights"); + assert_eq!(l.len(), LAYERS); + assert_eq!((l[0].channels, l[0].filters, l[0].depthwise), (32, 16, false)); + assert_eq!((l[2].channels, l[2].filters, l[2].depthwise), (16, 16, true)); + assert!(l.iter().all(|l| l.weights.iter().chain(&l.biases).all(|v| v.is_finite()))); + // Damaged weights are refused, not read past their end. + assert!(parse(&WEIGHTS[..WEIGHTS.len() - 1]).is_none()); + assert!(parse(b"nope").is_none()); + } + + /// The sum of every head's raw output on a fixed test image matches libfacedetection's own C++ + /// (computed with its f32 weights; ours are f16, hence the tolerance). + #[test] + fn heads_match_the_reference_implementation() { + let (w, h) = (96usize, 72usize); + let bgr: Vec<[f32; 3]> = (0..w * h) + .map(|i| { + let (x, y) = (i % w, i / w); + let (r, g, b) = ((x * 7 + y * 3) % 256, (x * x + y * 5) % 256, (x * y * 3 + 11) % 256); + [b as f32, g as f32, r as f32] + }) + .collect(); + let l = parse(WEIGHTS).unwrap(); + let levels = features(&bgr, w, h, &l).unwrap(); + let reference = + [27.647039, 549.579923, -1645.299678, 914.784787, -6.149318, 35.705980, -454.742148, 163.847111, 1.267630, 28.415509, -85.662257, 62.294208]; + let mut got = Vec::new(); + for (feat, heads) in levels.iter().zip(HEADS) { + for first in heads { + let o = conv_dp(feat, &l[first], &l[first + 1], false).unwrap(); + got.push(o.data.iter().map(|&v| f64::from(v)).sum::()); + } + } + for (g, r) in got.iter().zip(reference) { + assert!((g - r).abs() <= 0.01 * r.abs().max(10.0), "{got:?}"); + } + } + + #[test] + fn f16_decoding() { + assert_eq!(f16_to_f32(0x3c00), 1.0); + assert_eq!(f16_to_f32(0xc000), -2.0); + assert_eq!(f16_to_f32(0x0000), 0.0); + assert_eq!(f16_to_f32(0x3555), 0.333_251_95); + assert_eq!(f16_to_f32(0x0001), 2.0f32.powi(-24)); + } +} diff --git a/crates/algo/src/lib.rs b/crates/algo/src/lib.rs index 49ab15871c..c2ff6d2e6c 100644 --- a/crates/algo/src/lib.rs +++ b/crates/algo/src/lib.rs @@ -32,6 +32,7 @@ mod distort; mod distort2; pub mod erase; pub mod exif; +pub mod faces; pub mod features; mod fxutil; mod gallery; diff --git a/crates/engine/src/commands.rs b/crates/engine/src/commands.rs index 2f04187128..48b576dcb4 100644 --- a/crates/engine/src/commands.rs +++ b/crates/engine/src/commands.rs @@ -1422,6 +1422,7 @@ fn build() -> Vec { v.extend(crate::edit_menu_cmds::specs()); v.extend(crate::fill_key_cmds::specs()); v.extend(crate::sample_cmds::specs()); + v.extend(crate::face_cmds::specs()); v.extend(crate::sampler_cmds::specs()); v.extend(crate::brush_key_cmds::specs()); v.extend(crate::stamp_cmds::specs()); diff --git a/crates/engine/src/face_cmds.rs b/crates/engine/src/face_cmds.rs new file mode 100644 index 0000000000..d48df95b84 --- /dev/null +++ b/crates/engine/src/face_cmds.rs @@ -0,0 +1,115 @@ +//! Face detection (#3603): the faces in the document, with their box and five landmarks, from +//! [`photocraft_algo::faces`]. Select › Color Range's Detect Faces and Face-Aware Liquify read +//! faces through [`faces_in`]; `image.detectFaces` reports them to scripts and agents. + +use photocraft_algo::faces::{self, Face}; +use photocraft_doc::{Document, LayerId}; +use serde_json::{Value, json}; + +use crate::commands::CommandSpec; +use crate::{EngineError, Result, Session}; + +/// Faces scoring below this are left out unless asked for (libfacedetection's own threshold for a +/// reliable face). +pub const MIN_SCORE: f32 = 0.5; + +/// Why there are no faces in the browser build (see [`photocraft_algo::faces`]). +const UNAVAILABLE: &str = "face detection isn't available in the browser version of PhotoCraft; use the desktop app"; + +/// The faces in `px` (`w × h` straight RGBA), as [`faces::detect`] finds them. +pub fn detect(px: &[[f32; 4]], w: usize, h: usize, min_score: f32) -> Result> { + if !faces::AVAILABLE { + return Err(EngineError::Other(UNAVAILABLE.into())); + } + faces::detect(px, w, h, min_score).ok_or_else(|| EngineError::Other("face detection failed".into())) +} + +/// The faces in `doc`, best first: in the composite, or in the active layer's pixels when +/// `all_layers` is off. Coordinates are document pixels. +pub fn faces_in(doc: &Document, active: Option, all_layers: bool, min_score: f32) -> Result> { + let area = doc.bounds(); + let (w, h) = (area.width() as usize, area.height() as usize); + let surface = active.and_then(|id| doc.layer(id)).and_then(|l| l.surface()).filter(|_| !all_layers); + let px = match surface { + Some(surf) => { + let mut px = vec![[0.0f32; 4]; w * h]; + surf.read_rgba_into(area, &mut px); + px + } + None => photocraft_compose::render(doc, area).px, + }; + let found = detect(&px, w, h, min_score)?; + let (x0, y0) = (area.x0 as f32, area.y0 as f32); + Ok(found + .into_iter() + .map(|f| Face { rect: [f.rect[0] + x0, f.rect[1] + y0, f.rect[2], f.rect[3]], landmarks: f.landmarks.map(|[x, y]| [x + x0, y + y0]), ..f }) + .collect()) +} + +fn face_json(f: &Face) -> Value { + let pt = |i: usize| json!([f.landmarks[i][0], f.landmarks[i][1]]); + json!({ + "score": f.score, + "rect": f.rect, + "landmarks": {"rightEye": pt(0), "leftEye": pt(1), "nose": pt(2), "mouthRight": pt(3), "mouthLeft": pt(4)}, + }) +} + +fn detect_faces(s: &mut Session, p: &Value) -> Result { + const CMD: &str = "image.detectFaces"; + let bad = |msg: &str| EngineError::BadParams { cmd: CMD.into(), msg: msg.into() }; + let min_score = match p.get("minScore") { + None => MIN_SCORE, + Some(v) => v.as_f64().filter(|v| (0.0..=1.0).contains(v)).ok_or_else(|| bad("minScore must be a number in 0..1"))? as f32, + }; + let all_layers = match p.get("sampleAllLayers") { + None => true, + Some(v) => v.as_bool().ok_or_else(|| bad("sampleAllLayers must be true or false"))?, + }; + let d = s.active().ok_or(EngineError::NoDocument)?; + let found = faces_in(&d.doc, d.active_layer, all_layers, min_score)?; + Ok(json!({ "faces": found.iter().map(face_json).collect::>() })) +} + +pub fn specs() -> Vec { + vec![CommandSpec { + id: "image.detectFaces", + label: "Detect Faces", + menu: &[], + shortcut: None, + params: r##"{"minScore":0..1=0.5,"sampleAllLayers":bool=true} → {"faces":[{"score","rect":[x,y,w,h],"landmarks":{"rightEye","leftEye","nose","mouthRight","mouthLeft"}: [x,y] each},…]} (best first; "rightEye" is the one on the image's left)"##, + enabled: |s| { + if !faces::AVAILABLE { + return Err(UNAVAILABLE.into()); + } + s.active().map(|_| ()).ok_or_else(|| "no document open".into()) + }, + run: detect_faces, + journal: false, + }] +} + +#[cfg(test)] +mod tests { + use serde_json::json; + + use crate::Session; + + #[test] + fn detect_faces_reports_no_faces_on_a_plain_image_and_validates_params() { + let mut s = Session::new(); + assert!(s.execute("image.detectFaces", json!({})).is_err(), "no document"); + assert!(!s.is_enabled("image.detectFaces")); + s.execute("file.new", json!({"width": 64, "height": 48, "background": "white"})).unwrap(); + assert!(s.is_enabled("image.detectFaces")); + s.execute("select.all", json!({})).unwrap(); + assert_eq!(s.execute("image.detectFaces", json!({})).unwrap(), json!({"faces": []})); + assert_eq!(s.execute("image.detectFaces", json!({"minScore": 0, "sampleAllLayers": false})).unwrap(), json!({"faces": []})); + // No history step of its own: Undo undoes the Select All before it. + s.execute("edit.undo", json!({})).unwrap(); + assert!(s.active().unwrap().doc.selection.is_none()); + for bad in [json!({"minScore": 2}), json!({"minScore": "high"}), json!({"sampleAllLayers": 1})] { + assert!(s.execute("image.detectFaces", bad.clone()).is_err(), "{bad}"); + } + } +} diff --git a/crates/engine/src/lib.rs b/crates/engine/src/lib.rs index b8d6167c3d..0bf4d1b40f 100644 --- a/crates/engine/src/lib.rs +++ b/crates/engine/src/lib.rs @@ -41,6 +41,7 @@ pub mod eraser_cmds; pub mod export_preset_cmds; pub mod exr_cmds; pub mod extra_cmds; +pub mod face_cmds; pub mod file_cmds; pub mod fill_cmds; pub mod fill_key_cmds; From 5a33441af3bf5d9b93550055fe540a4ed3b448b8 Mon Sep 17 00:00:00 2001 From: Gert Pellin - GPE Date: Sat, 17 Oct 2026 02:34:40 +0200 Subject: [PATCH 52/85] fix(engine): an unknown layer id or name lists the document's layers, and a name matches regardless of case as a fallback (#5058) (#5133) A command that fails on a layer id or name the document doesn't have now says which layers there are, top-down as id and name (at most 20, names cut at 64 characters), so an agent can recover from the error alone: ids are not positions, and a freshly opened PNG's Background is layer 2. NoLayer becomes UnknownLayer { id, known } at both command entry points, keeping the 'no such layer' prefix. A layer name that matches no layer exactly matches regardless of case; an exact match always wins. Fixes #5058. --- crates/engine/src/commands.rs | 35 +++++++++-- crates/engine/src/jobs.rs | 16 ++++- crates/engine/src/lib.rs | 4 ++ crates/engine/src/type_caret_cmds.rs | 2 +- crates/engine/tests/missing_layer_error.rs | 68 ++++++++++++++++++++++ 5 files changed, 116 insertions(+), 9 deletions(-) create mode 100644 crates/engine/tests/missing_layer_error.rs diff --git a/crates/engine/src/commands.rs b/crates/engine/src/commands.rs index 48b576dcb4..ced8462c9e 100644 --- a/crates/engine/src/commands.rs +++ b/crates/engine/src/commands.rs @@ -295,7 +295,8 @@ fn set_clipped(s: &mut Session, p: &Value, clip: bool) -> Result { /// The layer a command's `"layer"` param names: an id, or a name (the topmost layer of that /// name, as Photoshop's "select layer by name" action step finds it, so a loaded action replays -/// in another document). `None` when the param is absent. +/// in another document; a name that matches no layer exactly matches regardless of case, so +/// `"background"` finds the Background). `None` when the param is absent. pub(crate) fn explicit_layer(s: &Session, p: &Value) -> Result> { match p.get("layer") { Some(Value::Number(n)) => { @@ -303,19 +304,41 @@ pub(crate) fn explicit_layer(s: &Session, p: &Value) -> Result> } Some(Value::String(name)) => { let d = s.active().ok_or(EngineError::NoDocument)?; - d.doc - .walk() - .into_iter() - .rev() + let layers = d.doc.walk(); + let mut top_down = layers.iter().rev(); + top_down + .clone() .find(|(_, _, l)| l.name == *name) + .or_else(|| top_down.find(|(_, _, l)| l.name.to_lowercase() == name.to_lowercase())) .map(|(_, _, l)| Some(l.id)) - .ok_or_else(|| EngineError::Other(format!("no layer named \"{name}\""))) + .ok_or_else(|| EngineError::Other(format!("no layer named \"{name}\"; {}", layer_listing(&d.doc)))) } Some(Value::Null) | None => Ok(None), Some(other) => Err(EngineError::Other(format!("`layer` must be an id or a name, not {other}"))), } } +/// The layers of `doc` as a caller can name them, for an error about a layer that isn't there: +/// `the document's layers are 2 "Background", 3 "Layer 1"` (top-down, at most 20). +pub(crate) fn layer_listing(doc: &Document) -> String { + const MAX: usize = 20; + let layers = doc.walk(); + if layers.is_empty() { + return "the document has no layers".into(); + } + // Names are unbounded input: each one is cut at NAME characters so the error stays short. + const NAME: usize = 64; + let name = |n: &str| match n.char_indices().nth(NAME) { + Some((cut, _)) => format!("{}…", &n[..cut]), + None => n.to_string(), + }; + let mut list: Vec = layers.iter().rev().take(MAX).map(|(_, _, l)| format!("{} {:?}", l.id.0, name(&l.name))).collect(); + if layers.len() > MAX { + list.push(format!("… {} more (see `document.inspect`)", layers.len() - MAX)); + } + format!("the document's layers are {} (a `layer` param takes an id or a name)", list.join(", ")) +} + /// The layer a command targets: [`explicit_layer`], else the active layer. pub(crate) fn layer_param(s: &Session, p: &Value) -> Result { match explicit_layer(s, p)? { diff --git a/crates/engine/src/jobs.rs b/crates/engine/src/jobs.rs index de2afd1556..17d8288f8b 100644 --- a/crates/engine/src/jobs.rs +++ b/crates/engine/src/jobs.rs @@ -543,7 +543,7 @@ impl Session { job.ctx.cancel(); Err(e) } - (Err(e), None) => Err(e), + (Err(e), None) => Err(self.explain_missing_layer(e)), (Ok(_), Some(mut job)) => { job.command = command.to_string(); job.params = params; @@ -740,7 +740,7 @@ impl Session { job.ctx.cancel(); Err(e) } - (Err(e), None) => Err(e), + (Err(e), None) => Err(self.explain_missing_layer(e)), (Ok(_), Some(mut job)) => { job.command = id.to_string(); job.params = params; @@ -756,6 +756,18 @@ impl Session { } } + /// A command that failed on a layer id the document doesn't have says which ids it does have + /// (#5058): ids are not positions (an opened PNG's Background is not layer 1), and an agent + /// should not need `document.inspect` to recover. + fn explain_missing_layer(&self, e: EngineError) -> EngineError { + match (e, self.active()) { + (EngineError::NoLayer(id), Some(st)) if st.doc.layer(id).is_none() => { + EngineError::UnknownLayer { id, known: crate::commands::layer_listing(&st.doc) } + } + (e, _) => e, + } + } + /// Bookkeeping after a command (or a job's apply) succeeded. /// /// `nested_from` is the journal length when the command started. A replayable command stands diff --git a/crates/engine/src/lib.rs b/crates/engine/src/lib.rs index 0bf4d1b40f..22b1e6d728 100644 --- a/crates/engine/src/lib.rs +++ b/crates/engine/src/lib.rs @@ -156,6 +156,10 @@ pub enum EngineError { NoDocument, #[error("no such layer {0:?}")] NoLayer(LayerId), + /// [`Self::NoLayer`] as a command reports it: with the layers the document does have, as + /// ids are not positions (#5058). + #[error("no such layer {}; {known}", id.0)] + UnknownLayer { id: LayerId, known: String }, #[error("{0}")] Other(String), /// A background job was cancelled (see [`jobs`]); nothing changed. diff --git a/crates/engine/src/type_caret_cmds.rs b/crates/engine/src/type_caret_cmds.rs index 419d305ee6..3b9b244f70 100644 --- a/crates/engine/src/type_caret_cmds.rs +++ b/crates/engine/src/type_caret_cmds.rs @@ -482,7 +482,7 @@ mod tests { bad(&mut s, "type.navigate", json!({"layer": id, "index": 9, "move": "end"})); bad(&mut s, "type.navigate", json!({"layer": id, "index": 0, "move": "lineNext", "x": "no"})); bad(&mut s, "type.navigate", json!({"layer": pixel, "index": 0, "move": "start"})); - assert!(matches!(s.execute("type.caret", json!({"layer": u64::MAX, "index": 0})), Err(EngineError::NoLayer(_)))); + assert!(matches!(s.execute("type.caret", json!({"layer": u64::MAX, "index": 0})), Err(EngineError::UnknownLayer { .. }))); // A point on the named layer but outside its text still returns a caret, marked outside. let far = s.execute("type.hitTest", json!({"layer": id, "x": 390, "y": 290})).unwrap(); assert_eq!(far["inside"], false); diff --git a/crates/engine/tests/missing_layer_error.rs b/crates/engine/tests/missing_layer_error.rs new file mode 100644 index 0000000000..5764f5d85c --- /dev/null +++ b/crates/engine/tests/missing_layer_error.rs @@ -0,0 +1,68 @@ +//! A `"layer"` param that names no layer says which layers there are (#5058): ids are not +//! positions (a freshly opened PNG's Background is layer 2), and an agent should be able to +//! recover from the error alone. A name matches regardless of case as a fallback. + +use photocraft_engine::{EngineError, Session}; +use serde_json::json; + +fn session() -> (Session, u64) { + let mut s = Session::new(); + s.execute("file.new", json!({"width": 8, "height": 6})).unwrap(); + let bg = s.active().unwrap().active_layer.unwrap().0; + (s, bg) +} + +fn unused_id(s: &Session) -> u64 { + let doc = &s.active().unwrap().doc; + (1..).find(|id| doc.layer(photocraft_doc::LayerId(*id)).is_none()).unwrap() +} + +#[test] +fn an_unknown_layer_id_lists_the_documents_layers() { + let (mut s, bg) = session(); + let layer = s.execute("layer.new.layer", json!({})).unwrap()["layer"].as_u64().unwrap(); + let missing = unused_id(&s); + let err = s.execute("layer.setProps", json!({"layer": missing, "visible": false})).unwrap_err(); + assert!(matches!(err, EngineError::UnknownLayer { id, .. } if id.0 == missing), "{err}"); + let err = err.to_string(); + assert!(err.contains(&format!("no such layer {missing};")), "{err}"); + assert!(err.contains(&format!("{layer} \"Layer 1\", {bg} \"Background\"")), "top-down ids and names: {err}"); +} + +#[test] +fn an_unknown_layer_name_lists_the_documents_layers() { + let (mut s, bg) = session(); + let err = s.execute("layer.setProps", json!({"layer": "Nope", "visible": false})).unwrap_err().to_string(); + assert!(err.contains("no layer named \"Nope\""), "{err}"); + assert!(err.contains(&format!("{bg} \"Background\"")), "{err}"); +} + +#[test] +fn a_layer_name_matches_regardless_of_case() { + let (mut s, bg) = session(); + s.execute("layer.new.layer", json!({})).unwrap(); + s.execute("layer.setProps", json!({"layer": "background", "name": "Base"})).unwrap(); + assert_eq!(s.active().unwrap().doc.layer(photocraft_doc::LayerId(bg)).unwrap().name, "Base"); +} + +#[test] +fn an_exact_name_wins_over_a_case_insensitive_one() { + let (mut s, _) = session(); + let lower = s.execute("layer.new.layer", json!({"name": "shadow"})).unwrap()["layer"].as_u64().unwrap(); + let upper = s.execute("layer.new.layer", json!({"name": "Shadow"})).unwrap()["layer"].as_u64().unwrap(); + s.execute("layer.setProps", json!({"layer": "shadow", "visible": false})).unwrap(); + let doc = &s.active().unwrap().doc; + assert!(!doc.layer(photocraft_doc::LayerId(lower)).unwrap().visible); + assert!(doc.layer(photocraft_doc::LayerId(upper)).unwrap().visible); +} + +#[test] +fn a_long_layer_name_is_shortened_in_the_listing() { + let (mut s, _) = session(); + let long = "é".repeat(500); + let layer = s.execute("layer.new.layer", json!({"name": long})).unwrap()["layer"].as_u64().unwrap(); + let err = s.execute("layer.setProps", json!({"layer": "Nope", "visible": false})).unwrap_err().to_string(); + let shown = format!("{layer} \"{}…\"", "é".repeat(64)); + assert!(err.contains(&shown), "the name is cut at 64 characters: {err}"); + assert!(err.chars().count() < 300, "{err}"); +} From 3722dfbc63e862366ed3ba1cc38d8868d6e888fd Mon Sep 17 00:00:00 2001 From: Michael Higgins <55243596+Higgs32584@users.noreply.github.com> Date: Fri, 16 Oct 2026 20:36:06 -0400 Subject: [PATCH 53/85] fix(ui): Arabic and Hebrew layer names and the title read right to left (#5159) (#5385) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit egui places font runs left to right with no bidi reordering, so a right-to-left layer name showed every word correctly but in reversed order. bidi_text::visual puts a label into display order with the Unicode Bidirectional Algorithm (unicode-bidi, already in the tree via usvg): right-to-left words stay in logical order for the shaper, everything else in a right-to-left run is reversed around them and brackets are mirrored. The Layers panel's rows and the app-drawn title bar use it; the title keeps a left-to-right paragraph so '— PhotoCraft' stays at the right, and a long right-to-left name is cut at its logical end so its start survives. Display only: stored names and the OS title keep the logical text. Fixes #5159. Co-authored-by: ahmedhosnypro --- Cargo.lock | 1 + crates/ui-egui/Cargo.toml | 2 + crates/ui-egui/src/bidi_text.rs | 203 +++++++++++++++++++++++++++++ crates/ui-egui/src/layer_row_ui.rs | 21 ++- crates/ui-egui/src/lib.rs | 1 + crates/ui-egui/src/panels.rs | 3 + 6 files changed, 227 insertions(+), 4 deletions(-) create mode 100644 crates/ui-egui/src/bidi_text.rs diff --git a/Cargo.lock b/Cargo.lock index 138ca3d814..0167dea3b1 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -5037,6 +5037,7 @@ dependencies = [ "serde_json", "sys-locale", "toml", + "unicode-bidi", "winsafe", ] diff --git a/crates/ui-egui/Cargo.toml b/crates/ui-egui/Cargo.toml index 61f21885f9..e5bf497c81 100644 --- a/crates/ui-egui/Cargo.toml +++ b/crates/ui-egui/Cargo.toml @@ -35,6 +35,8 @@ eframe = { workspace = true, features = ["wgpu", "accesskit"] } serde = { workspace = true } serde_json = { workspace = true } log = { workspace = true } +# Display order of right-to-left labels (`bidi_text.rs`); already linked through usvg. +unicode-bidi = "0.3" base64 = "0.22" # Inflating the translation catalogs, stored deflated (build.rs); already linked through photocraft-io. flate2 = { workspace = true } diff --git a/crates/ui-egui/src/bidi_text.rs b/crates/ui-egui/src/bidi_text.rs new file mode 100644 index 0000000000..babb0d0c48 --- /dev/null +++ b/crates/ui-egui/src/bidi_text.rs @@ -0,0 +1,203 @@ +//! Right-to-left text in labels that egui draws (#5159). +//! +//! egui shapes text in runs of one font face and places the runs left to right. An Arabic or +//! Hebrew word is one run, which the shaper draws right to left, but the space between two words +//! comes from another font, so a phrase came out with every word correct and the word order +//! reversed ("THIS IS A TEXT" read as "TEXT A IS THIS"). [`visual`] puts a label into display +//! order with the Unicode Bidirectional Algorithm before egui lays it out: words of a +//! right-to-left script stay in logical order (the shaper reverses them itself), and everything +//! else in a right-to-left run is reversed around them, with brackets mirrored. +//! +//! For display only: never store the result or put it in an editable field. + +use std::borrow::Cow; + +use unicode_bidi::{BidiClass, BidiInfo, Level, bidi_class}; + +/// `text` in the order egui should lay it out from left to right. Text without right-to-left +/// characters comes back unchanged, without allocating. +pub fn visual(text: &str) -> Cow<'_, str> { + visual_at(text, None) +} + +/// [`visual`] for a line that reads left to right as a whole even when it starts with a +/// right-to-left name, such as the title bar's "*صورة.psd — PhotoCraft": with the paragraph +/// direction taken from the name, the "— PhotoCraft" tail moved to the left. +pub fn visual_ltr(text: &str) -> Cow<'_, str> { + visual_at(text, Some(Level::ltr())) +} + +/// Whether `text` reads right to left as a paragraph: its first strong letter is Hebrew, Arabic… +pub fn is_rtl_paragraph(text: &str) -> bool { + text.chars() + .find_map(|c| match bidi_class(c) { + BidiClass::L => Some(false), + BidiClass::R | BidiClass::AL => Some(true), + _ => None, + }) + .unwrap_or(false) +} + +/// `text` in display order, cut with "…" where it doesn't fit (`fits` measures a display-order +/// string). A right-to-left name is cut in logical order before it is reordered, so its *start* +/// stays and the "…" lands at the left, the end of a right-to-left line; cutting after the +/// reordering lost the start of the name instead. `None`: `text` fits as it is (or a +/// left-to-right one, which the caller elides at its right end as usual). +pub fn elide(text: &str, fits: impl Fn(&str) -> bool) -> Option { + if !is_rtl_paragraph(text) || fits(&visual(text)) { + return None; + } + // The longest logical prefix (whole characters) that fits with "…" after it. + let ends: Vec = text.char_indices().map(|(i, _)| i).skip(1).chain(std::iter::once(text.len())).collect(); + let cut = |n: usize| -> String { + let end = if n == 0 { 0 } else { ends.get(n - 1).copied().unwrap_or(text.len()) }; + visual(&format!("{}…", text.get(..end).unwrap_or_default().trim_end())).into_owned() + }; + let (mut lo, mut hi) = (0usize, ends.len()); + while lo < hi { + let mid = (lo + hi).div_ceil(2); + if fits(&cut(mid)) { lo = mid } else { hi = mid - 1 } + } + Some(cut(lo)) +} + +fn visual_at(text: &str, level: Option) -> Cow<'_, str> { + if !text.chars().any(is_rtl) { + return Cow::Borrowed(text); + } + let info = BidiInfo::new(text, level); + let mut out = String::with_capacity(text.len()); + for para in &info.paragraphs { + let (levels, runs) = info.visual_runs(para, para.range.clone()); + for run in runs { + let Some(slice) = text.get(run.clone()) else { continue }; + if levels.get(run.start).is_some_and(|level| level.is_rtl()) { + push_reversed(slice, &mut out); + } else { + out.push_str(slice); + } + } + } + Cow::Owned(out) +} + +/// A strong right-to-left letter, or any character of a right-to-left script block (Arabic +/// punctuation such as "،" belongs with the word it follows: the same font shapes both). +fn is_rtl(c: char) -> bool { + matches!(bidi_class(c), BidiClass::R | BidiClass::AL) || matches!(c, '\u{0590}'..='\u{08FF}' | '\u{FB1D}'..='\u{FDFF}' | '\u{FE70}'..='\u{FEFC}') +} + +/// Characters that stay inside a right-to-left word: its letters, combining marks, and joiners. +fn keeps_with_word(c: char) -> bool { + is_rtl(c) || matches!(bidi_class(c), BidiClass::NSM | BidiClass::BN) +} + +/// Append a right-to-left run in left-to-right display order: right-to-left words stay whole and +/// in logical order, every other character is reversed around them, and brackets are mirrored. +fn push_reversed(run: &str, out: &mut String) { + let mut units: Vec<&str> = Vec::new(); + let mut word: Option = None; + for (i, c) in run.char_indices() { + if keeps_with_word(c) { + word.get_or_insert(i); + continue; + } + if let Some(start) = word.take() + && let Some(w) = run.get(start..i) + { + units.push(w); + } + if let Some(single) = run.get(i..i + c.len_utf8()) { + units.push(single); + } + } + if let Some(start) = word + && let Some(w) = run.get(start..) + { + units.push(w); + } + for unit in units.into_iter().rev() { + let mut chars = unit.chars(); + match (chars.next(), chars.next()) { + (Some(c), None) if !keeps_with_word(c) => out.push(mirror(c)), + _ => out.push_str(unit), + } + } +} + +/// The mirrored form of a bracket shown in right-to-left text. +fn mirror(c: char) -> char { + match c { + '(' => ')', + ')' => '(', + '[' => ']', + ']' => '[', + '{' => '}', + '}' => '{', + '<' => '>', + '>' => '<', + '«' => '»', + '»' => '«', + other => other, + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn left_to_right_text_is_untouched_and_not_copied() { + for text in ["PhotoCraft", "Layer 1", "", "Background copy (2)", "日本語のレイヤー"] { + assert!(matches!(visual(text), Cow::Borrowed(t) if t == text), "{text}"); + } + } + + /// #5159: the reported layer name. Each word stays as typed; the word order flips so that + /// egui, placing words left to right, shows the first word at the right. + #[test] + fn arabic_words_are_placed_right_to_left() { + assert_eq!(visual("لاشي مكتوب هنا"), "هنا مكتوب لاشي"); + assert_eq!(visual("שלום עולם"), "עולם שלום"); + } + + #[test] + fn numbers_and_latin_keep_their_own_order() { + // A number inside right-to-left text reads left to right, at the start of the line. + assert_eq!(visual("طبقة 2"), "2 طبقة"); + assert_eq!(visual("طبقة 12"), "12 طبقة"); + // A left-to-right name with an Arabic word at the end is left as it is. + assert_eq!(visual("Layer 1 نص"), "Layer 1 نص"); + // Latin inside an Arabic name stays readable. + assert_eq!(visual("نص PhotoCraft هنا"), "هنا PhotoCraft نص"); + } + + /// #5385 review: the title bar's line is left to right as a whole, so a right-to-left + /// document name keeps "— PhotoCraft" after it, at the right. + #[test] + fn the_title_line_stays_left_to_right_around_a_right_to_left_name() { + assert_eq!(visual_ltr("*صورة.psd — PhotoCraft"), "*صورة.psd — PhotoCraft"); + assert_eq!(visual_ltr("لاشي مكتوب.png — PhotoCraft"), "مكتوب لاشي.png — PhotoCraft"); + assert!(matches!(visual_ltr("Untitled-1 — PhotoCraft"), Cow::Borrowed(_))); + } + + /// #5385 review: a right-to-left name that doesn't fit keeps its first words and ends with + /// "…" at the left; a left-to-right or fitting name is left to the caller. + #[test] + fn right_to_left_names_are_cut_at_their_logical_end() { + let fits = |n: usize| move |s: &str| s.chars().count() <= n; + // Cut anywhere, as egui elides: the first word and the start of the second. + assert_eq!(elide("لاشي مكتوب هنا", fits(8)).as_deref(), Some("…مك لاشي")); + assert_eq!(elide("לשלום עולם גדול", fits(11)).as_deref(), Some("…עולם לשלום")); + assert_eq!(elide("لاشي مكتوب هنا", fits(40)), None, "fits"); + assert_eq!(elide("Layer copy 12", fits(4)), None, "left to right: elided by egui at its end"); + assert_eq!(elide("نص", fits(0)).as_deref(), Some("…"), "nothing fits: just the ellipsis"); + assert!(is_rtl_paragraph("12 نص") && !is_rtl_paragraph("Layer نص") && !is_rtl_paragraph("123")); + } + + #[test] + fn brackets_mirror_and_arabic_punctuation_stays_with_its_word() { + assert_eq!(visual("نص (1)"), "(1) نص"); + assert_eq!(visual("مرحبا، عالم"), "عالم مرحبا،"); + } +} diff --git a/crates/ui-egui/src/layer_row_ui.rs b/crates/ui-egui/src/layer_row_ui.rs index 5a6e60f792..4bed6a7cc4 100644 --- a/crates/ui-egui/src/layer_row_ui.rs +++ b/crates/ui-egui/src/layer_row_ui.rs @@ -94,10 +94,23 @@ pub fn truncated(painter: &Painter, text: &str, font: FontId, color: egui::Color if max_width < 8.0 { return None; } - let mut job = egui::text::LayoutJob::default(); - job.append(text, 0.0, egui::TextFormat { font_id: font, color, italics, ..Default::default() }); - job.wrap = egui::text::TextWrapping { max_width, max_rows: 1, break_anywhere: true, overflow_character: Some('…') }; - Some(painter.layout_job(job)) + let format = egui::text::TextFormat { font_id: font, color, italics, ..Default::default() }; + let layout = |s: &str, wrap: bool| { + let mut job = egui::text::LayoutJob::default(); + job.append(s, 0.0, format.clone()); + if wrap { + job.wrap = egui::text::TextWrapping { max_width, max_rows: 1, break_anywhere: true, overflow_character: Some('…') }; + } + painter.layout_job(job) + }; + // A right-to-left name (Arabic, Hebrew) too long for the row is cut at its logical end and + // then put in display order, so its start stays and "…" is at the left (`bidi_text::elide`). + if let Some(shown) = crate::bidi_text::elide(text, |s| layout(s, false).size().x <= max_width) { + return Some(layout(&shown, false)); + } + // Right-to-left names in display order: egui lays words out left to right. Left-to-right + // names are cut at their right end by egui. + Some(layout(&crate::bidi_text::visual(text), true)) } /// Paint `l`'s right-hand indicators in `row` and handle the effects triangle. Returns the diff --git a/crates/ui-egui/src/lib.rs b/crates/ui-egui/src/lib.rs index e68a6d555b..4c8d64832b 100644 --- a/crates/ui-egui/src/lib.rs +++ b/crates/ui-egui/src/lib.rs @@ -25,6 +25,7 @@ pub mod anchor_tools; pub mod artboard_tool; pub mod artboard_ui; pub(crate) mod background_redraw; +pub mod bidi_text; mod blend_keys; pub(crate) mod blend_preview; pub mod blur_pins; diff --git a/crates/ui-egui/src/panels.rs b/crates/ui-egui/src/panels.rs index 177ad521ba..6fc8e2d8f4 100644 --- a/crates/ui-egui/src/panels.rs +++ b/crates/ui-egui/src/panels.rs @@ -694,6 +694,9 @@ pub fn title_bar(app: &mut PhotocraftApp, ui: &mut egui::Ui) { if system { return; } + // A right-to-left document name in display order (the OS title keeps the logical text), + // in a left-to-right line: "— PhotoCraft" stays after the name, at the right. + let title = crate::bidi_text::visual_ltr(&title).into_owned(); let font = theme::medium(13.0); let galley = ui.painter().layout_no_wrap(title.clone(), font.clone(), t.text_dim); if let Some(x) = title_x(full.center().x, free.0, free.1, galley.size().x) { From d900daa0ba4376b59edcfab92d5b712695b1bc90 Mon Sep 17 00:00:00 2001 From: Gustavo Ziger <44277841+zi-ger@users.noreply.github.com> Date: Fri, 16 Oct 2026 21:38:56 -0300 Subject: [PATCH 54/85] =?UTF-8?q?fix(web):=20File=20=E2=80=BA=20Place=20Em?= =?UTF-8?q?bedded=20is=20enabled=20in=20the=20browser=20and=20places=20the?= =?UTF-8?q?=20picked=20file's=20bytes=20(#1209,=20#5749)=20(#5585)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit With a document open in the web app, Place Embedded was disabled because its menu enablement inherited the engine's native-filesystem check, although the menu path reads the picker's bytes and never touches a path. Place Embedded is now enabled whenever a document and a file-dialog service exist; Place Linked keeps the engine's native guard, since the web could only link to a bare file name (caught in #5817 by Higgs32584). An enablement test runs with the native check forced off, and a file-dialog test covers the browser answer shape, cancel, invalid bytes and the Free Transform handoff. Fixes #1209. Fixes #5749. --- Cargo.toml | 8 +++++ apps/photocraft-web/src/main.rs | 6 ++-- crates/ui-egui/src/file_dialog/tests.rs | 47 +++++++++++++++++++++++++ crates/ui-egui/src/view_cmds.rs | 10 +++++- crates/ui-egui/src/view_cmds/tests.rs | 26 ++++++++++++++ docs/development.md | 4 +-- 6 files changed, 95 insertions(+), 6 deletions(-) diff --git a/Cargo.toml b/Cargo.toml index 0d0f93ae7c..4716812f03 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -136,6 +136,14 @@ opt-level = "z" [profile.wasm-release.package.naga] opt-level = "z" +# The browser UI toolkit favors size to stay within the WebAssembly upload budget. +[profile.wasm-release.package.egui] +opt-level = "z" +[profile.wasm-release.package.epaint] +opt-level = "z" +[profile.wasm-release.package.eframe] +opt-level = "z" + # 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), diff --git a/apps/photocraft-web/src/main.rs b/apps/photocraft-web/src/main.rs index 428143c880..0802a02ebf 100644 --- a/apps/photocraft-web/src/main.rs +++ b/apps/photocraft-web/src/main.rs @@ -6,10 +6,10 @@ //! //! Differences from the desktop app: //! - no TCP control server (browsers can't listen on sockets); -//! - File → Open uses the browser file picker; bytes arrive asynchronously through -//! `Services::inbox`; +//! - file pickers return browser file contents asynchronously through `Services::file_dialog`; +//! this includes File → Open and commands such as Place Embedded; //! - saving/exporting triggers a browser download; -//! - dropped files are read asynchronously by `web::WebShell` and delivered through the inbox. +//! - dropped files are read asynchronously by `web::WebShell` and delivered through `Services::inbox`. //! //! URL query flags: `?cpu` forces the CPU canvas path (same as `PHOTOCRAFT_CPU_CANVAS=1`); //! `?webgl` forces the WebGL2 backend instead of WebGPU. diff --git a/crates/ui-egui/src/file_dialog/tests.rs b/crates/ui-egui/src/file_dialog/tests.rs index 2a325ea2ce..9e4c878f68 100644 --- a/crates/ui-egui/src/file_dialog/tests.rs +++ b/crates/ui-egui/src/file_dialog/tests.rs @@ -687,6 +687,53 @@ fn smart_source(app: &PhotocraftApp, id: u64) -> photocraft_doc::SmartSource { } } +#[test] +fn place_embedded_uses_the_single_file_picker_and_accepts_contents() { + let (mut app, open, _) = app(); + let ctx = egui::Context::default(); + assert!(menus::is_enabled(&app, "file.placeEmbedded")); + let source = photocraft_io::export(&app.session.active().unwrap().doc, "source.png", &Default::default()).unwrap().bytes; + let layers_before = app.session.active().unwrap().doc.layers.len(); + + // Cancel leaves the document alone and does not report an error. + menus::invoke(&mut app, &ctx, "file.placeEmbedded", json!({})).unwrap(); + app.poll_file_dialog(&ctx, None); + assert!(matches!(open.borrow().as_slice(), [(FileDialogRequest::Open { multiple: false, .. }, _)])); + answer(&open, None); + app.poll_file_dialog(&ctx, None); + assert_eq!(app.session.active().unwrap().doc.layers.len(), layers_before); + assert!(!app.ui.status_error, "{}", app.ui.status); + + // The Web picker returns the selected file's name and bytes instead of a filesystem path. + menus::invoke(&mut app, &ctx, "file.placeEmbedded", json!({})).unwrap(); + app.poll_file_dialog(&ctx, None); + answer(&open, Some(FileDialogAnswer::Contents("source.png".into(), source))); + app.poll_file_dialog(&ctx, None); + assert!(!app.ui.status_error, "{}", app.ui.status); + let st = app.session.active().unwrap(); + assert_eq!(st.doc.layers.len(), layers_before + 1); + let layer = st.doc.layer(st.active_layer.unwrap()).unwrap(); + assert_eq!(layer.name, "source"); + assert_eq!(app.ui.transform.as_ref().and_then(|t| t.made), Some(crate::state::MadeLayer::Place)); + assert!(matches!( + &layer.content, + photocraft_doc::LayerContent::Smart(sm) + if matches!(&sm.source, photocraft_doc::SmartSource::Embedded { file_name, .. } if file_name == "source.png") + )); + + // Finish the successful placement before starting the next picker. + crate::transform_tool::commit(&mut app); + assert!(app.ui.transform.is_none()); + + // Invalid contents are reported without adding a layer. + menus::invoke(&mut app, &ctx, "file.placeEmbedded", json!({})).unwrap(); + app.poll_file_dialog(&ctx, None); + answer(&open, Some(FileDialogAnswer::Contents("broken.png".into(), b"not an image".to_vec()))); + app.poll_file_dialog(&ctx, None); + assert!(app.ui.status_error); + assert_eq!(app.session.active().unwrap().doc.layers.len(), layers_before + 1); +} + #[test] fn replace_contents_picks_a_file_for_every_instance() { let (mut app, open, _, ids) = smart_app(); diff --git a/crates/ui-egui/src/view_cmds.rs b/crates/ui-egui/src/view_cmds.rs index 8acd676f95..1da1f918d2 100644 --- a/crates/ui-egui/src/view_cmds.rs +++ b/crates/ui-egui/src/view_cmds.rs @@ -459,6 +459,11 @@ pub fn handles(id: &str) -> bool { } pub fn is_enabled(app: &PhotocraftApp, id: &str) -> Option { + is_enabled_with_engine(app, id, || app.session.is_enabled(id)) +} + +/// Keep picker enablement testable with the engine's native filesystem check disabled. +fn is_enabled_with_engine(app: &PhotocraftApp, id: &str, engine_enabled: impl FnOnce() -> bool) -> Option { if !handles(id) && !wraps(id) { return None; } @@ -476,7 +481,10 @@ pub fn is_enabled(app: &PhotocraftApp, id: &str) -> Option { || (doc && matches!(&i["window.arrange.".len()..], "floatAllInWindows" | "cascade" | "tile" | "tileAllVertically" | "tileAllHorizontally")) } }, - i if wraps(i) => app.session.is_enabled(i), + // Embedded placement reads the picker's bytes on every platform. Linked placement keeps + // the engine's native filesystem guard because its source must remain reachable. + "file.placeEmbedded" => doc && app.services.file_dialog.is_some(), + i if wraps(i) => engine_enabled(), _ => true, }) } diff --git a/crates/ui-egui/src/view_cmds/tests.rs b/crates/ui-egui/src/view_cmds/tests.rs index 2205b6e157..1abe863558 100644 --- a/crates/ui-egui/src/view_cmds/tests.rs +++ b/crates/ui-egui/src/view_cmds/tests.rs @@ -14,6 +14,32 @@ fn checked(app: &PhotocraftApp, id: &str) -> Option { menu_items(app).into_iter().find(|i| i.id == id).and_then(|i| i.checked) } +/// #1209: the browser picker supplies bytes even when the engine cannot read native paths. +#[test] +fn place_embedded_enablement_does_not_require_native_doc() { + for has_doc in [true, false] { + for has_picker in [true, false] { + let (mut app, _) = app_with(usize::from(has_doc)); + if has_picker { + app.services.file_dialog = Some(Box::new(|_, _, _| {})); + } + for native_filesystem in [false, true] { + let native_doc = native_filesystem && has_doc; + assert_eq!( + is_enabled_with_engine(&app, "file.placeEmbedded", || native_doc), + Some(has_doc && has_picker), + "Place Embedded: doc={has_doc}, picker={has_picker}, native_doc={native_doc}" + ); + assert_eq!( + is_enabled_with_engine(&app, "file.placeLinked", || native_doc), + Some(native_doc), + "Place Linked must retain the engine's native_doc guard" + ); + } + } + } +} + #[test] fn show_snap_extras_and_screen_modes_are_state() { let (mut app, ctx) = app_with(1); diff --git a/docs/development.md b/docs/development.md index 67f688b6b9..035efbbcf0 100644 --- a/docs/development.md +++ b/docs/development.md @@ -399,9 +399,9 @@ HTTPS/reverse-proxy deployment and browser limitations. How the web shell (`apps/photocraft-web/src/web.rs`) differs from desktop: -- **Open** uses `rfd::AsyncFileDialog`. The bytes arrive asynchronously in `Services::inbox`, which the app drains every frame. +- **File dialogs** use `rfd::AsyncFileDialog`. Chosen file contents arrive asynchronously through `Services::file_dialog`; this includes File › Open and single-file commands such as Place Embedded. - **Save / Save As / Export** trigger a browser download of the encoded bytes. The shell does this with a Blob, an object URL and a temporary ``, all created from Rust. There is no save dialog, so the suggested name becomes the download name. -- **Drag-and-drop:** `WebShell` takes the frame's `dropped_files` before the app sees them. It reads each file with `DroppedFile::bytes_async` and pushes the bytes into the inbox. +- **Drag-and-drop:** `WebShell` takes the frame's `dropped_files` before the app sees them. It reads each file with `DroppedFile::bytes_async` and pushes the bytes into `Services::inbox`. - **Startup failures:** `index.html` shows "Loading PhotoCraft…" until the app removes it, or replaces it with the error when the web runner fails to start. If the `.wasm` never downloads or doesn't match the page's `.js` (a stale cached `index.html` after a deploy), no Rust runs at all, so the page itself (CSS only, no script) adds a hint with a Reload link after 20 seconds. - **No control server:** browsers can't listen on TCP. To automate the web build, drive headless Chrome with `--remote-debugging-port`. `Page.setInterceptFileChooserDialog` plus `DOM.setFileInputFiles` covers Open, `Input.dispatchDragEvent` with `files` covers drops, and `Browser.setDownloadBehavior` captures downloads. - Headless Chrome on macOS (`--headless=new --enable-unsafe-webgpu`) gets a real WebGPU adapter. From 10fdcbbefcab2eda64f89d0f5ea95d4ec5b00460 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ivan=20=C2=B7=20VanTan?= <77483789+VanTan666@users.noreply.github.com> Date: Sat, 17 Oct 2026 02:42:36 +0200 Subject: [PATCH 55/85] chore: fix workspace Clippy collapsible_match warnings (#5190) Strict workspace Clippy stopped on collapsible_match in affinity, raw, plugins, engine, ui-egui and xtask. Pure conditions become guarded match arms (each falling through to an existing empty arm), and the ruler drag state and the measurement command result are bound to locals, preserving evaluation order, error messages and fallbacks. The same lint fixes also appear as a side commit in #5157 (David2766), which can drop it on rebase. --- crates/affinity/src/preview.rs | 7 ++----- crates/engine/src/color_to_alpha_cmds.rs | 5 ++--- crates/plugins/src/manifest.rs | 6 ++---- crates/raw/src/cr3.rs | 6 +----- crates/ui-egui/src/analysis_ui.rs | 6 ++++-- crates/ui-egui/src/canvas.rs | 14 ++++++-------- xtask/src/layers.rs | 7 ++----- 7 files changed, 19 insertions(+), 32 deletions(-) diff --git a/crates/affinity/src/preview.rs b/crates/affinity/src/preview.rs index f50cb8c3e9..d3341eeb1c 100644 --- a/crates/affinity/src/preview.rs +++ b/crates/affinity/src/preview.rs @@ -94,11 +94,8 @@ pub fn preview(bytes: &[u8]) -> Result, Error> { } image_data = true; } - b"IEND" => { - if !image_data { - return Err(Error::Malformed("PNG has no image data")); - } - } + b"IEND" if !image_data => return Err(Error::Malformed("PNG has no image data")), + b"IEND" => {} _ if kind.first().is_some_and(u8::is_ascii_uppercase) => { return Err(Error::Unsupported("unknown critical PNG chunk")); } diff --git a/crates/engine/src/color_to_alpha_cmds.rs b/crates/engine/src/color_to_alpha_cmds.rs index a6208e8246..34356a3bfb 100644 --- a/crates/engine/src/color_to_alpha_cmds.rs +++ b/crates/engine/src/color_to_alpha_cmds.rs @@ -64,10 +64,9 @@ fn validate(p: &Value) -> Result<()> { Some(Value::String(s)) => { crate::commands::parse_hex(s).ok_or_else(|| bad(format!("`color` must be \"#rrggbb\", not {s:?}")))?; } + Some(Value::Array(a)) if (3..=4).contains(&a.len()) && a.iter().all(|v| v.as_f64().is_some_and(|x| x.is_finite() && (0.0..=1.0).contains(&x))) => {} Some(Value::Array(a)) if (3..=4).contains(&a.len()) => { - if !a.iter().all(|v| v.as_f64().is_some_and(|x| x.is_finite() && (0.0..=1.0).contains(&x))) { - return Err(bad("`color` as an array must hold 3 or 4 numbers in 0..1")); - } + return Err(bad("`color` as an array must hold 3 or 4 numbers in 0..1")); } Some(_) => return Err(bad("`color` must be \"#rrggbb\" or [r, g, b] in 0..1")), } diff --git a/crates/plugins/src/manifest.rs b/crates/plugins/src/manifest.rs index ccabf47d55..52994bc740 100644 --- a/crates/plugins/src/manifest.rs +++ b/crates/plugins/src/manifest.rs @@ -122,10 +122,8 @@ impl Manifest { return Err(Error::Manifest(format!("invalid parameter name {k:?}"))); } match spec { - ParamSpec::Number { min, max, default } => { - if !(min.is_finite() && max.is_finite() && default.is_finite()) || min > max { - return Err(Error::Manifest(format!("parameter `{k}`: bad range"))); - } + ParamSpec::Number { min, max, default } if !(min.is_finite() && max.is_finite() && default.is_finite()) || min > max => { + return Err(Error::Manifest(format!("parameter `{k}`: bad range"))); } ParamSpec::Int { min, max, .. } if min > max => return Err(Error::Manifest(format!("parameter `{k}`: bad range"))), ParamSpec::Choice { options, default } => { diff --git a/crates/raw/src/cr3.rs b/crates/raw/src/cr3.rs index bb2119e7d4..04e7e2ec5f 100644 --- a/crates/raw/src/cr3.rs +++ b/crates/raw/src/cr3.rs @@ -141,11 +141,7 @@ fn walk<'a>(raw: &'a [u8], b: &'a [u8], depth: usize, budget: &mut usize, found: } boxes(b, budget, |item, budget| match item.kind { b"moov" => walk(raw, item.body, depth + 1, budget, found), - b"trak" => { - if found.previews.len() < 128 { - found.previews.extend(track_preview(raw, item.body, budget, &found.media)); - } - } + b"trak" if found.previews.len() < 128 => found.previews.extend(track_preview(raw, item.body, budget, &found.media)), b"uuid" => { if item.body.get(..16) == Some(META_UUID) && let Some(children) = item.body.get(16..) diff --git a/crates/ui-egui/src/analysis_ui.rs b/crates/ui-egui/src/analysis_ui.rs index 09aaf17670..527d58eda0 100644 --- a/crates/ui-egui/src/analysis_ui.rs +++ b/crates/ui-egui/src/analysis_ui.rs @@ -281,7 +281,8 @@ pub fn pointer(app: &mut PhotocraftApp, ev: ToolEvent, mods: egui::Modifiers) -> set_ruler(app, r); } (Tool::Ruler, ToolEvent::Up { .. }) => { - if app.ui.analysis.drag.take() == Some(Drag::RulerNew) && doc.measurement.ruler.is_some_and(|r| dist(r.start, r.end) < 0.5) { + let drag = app.ui.analysis.drag.take(); + if drag == Some(Drag::RulerNew) && doc.measurement.ruler.is_some_and(|r| dist(r.start, r.end) < 0.5) { let _ = app.run("image.analysis.rulerTool", json!({"clear": true})); } } @@ -743,7 +744,8 @@ pub fn windows(app: &mut PhotocraftApp, ctx: &egui::Context) { } } Some((id, p)) => { - if app.run(id, p).is_ok() { + let result = app.run(id, p); + if result.is_ok() { app.ui.analysis.log_selected.clear(); } } diff --git a/crates/ui-egui/src/canvas.rs b/crates/ui-egui/src/canvas.rs index ad2ddafa55..38c3b439b5 100644 --- a/crates/ui-egui/src/canvas.rs +++ b/crates/ui-egui/src/canvas.rs @@ -5846,14 +5846,12 @@ pub(crate) fn finish_gesture(app: &mut PhotocraftApp, d: Drag) { let _ = app.run("select.deselect", json!({})); } } - Tool::Gradient => { - if (end[0] - d.start[0]).abs() + (end[1] - d.start[1]).abs() >= 2.0 { - let o = app.ui.tool_options.clone(); - let _ = app.run( - "paint.gradient", - json!({"from": [d.start[0], d.start[1]], "to": [end[0], end[1]], "style": o.gradient_style, "reverse": o.gradient_reverse, "dither": o.gradient_dither, "opacity": o.fill_opacity, "mode": o.gradient_blend_mode.label(), "target": paint_target(app)}), - ); - } + Tool::Gradient if (end[0] - d.start[0]).abs() + (end[1] - d.start[1]).abs() >= 2.0 => { + let o = app.ui.tool_options.clone(); + let _ = app.run( + "paint.gradient", + json!({"from": [d.start[0], d.start[1]], "to": [end[0], end[1]], "style": o.gradient_style, "reverse": o.gradient_reverse, "dither": o.gradient_dither, "opacity": o.fill_opacity, "mode": o.gradient_blend_mode.label(), "target": paint_target(app)}), + ); } Tool::Move => { let (dx, dy) = ((end[0] - d.start[0]).round(), (end[1] - d.start[1]).round()); diff --git a/xtask/src/layers.rs b/xtask/src/layers.rs index ce34e8c25a..3d162dd458 100644 --- a/xtask/src/layers.rs +++ b/xtask/src/layers.rs @@ -200,11 +200,8 @@ pub fn check(crates: &[Crate]) -> Vec { match classify(&d.name) { // Unregistered deps are reported on their own entry. None => {} - Some(Class::Testkit) => { - if d.kind != DepKind::Dev { - out.push(Violation::TestkitAsNormalDep { krate: c.name.clone() }); - } - } + Some(Class::Testkit) if d.kind != DepKind::Dev => out.push(Violation::TestkitAsNormalDep { krate: c.name.clone() }), + Some(Class::Testkit) => {} Some(dc) => { let to = dc.layer().unwrap_or(u8::MAX); if to >= layer && !(to == layer && intra_layer_allowed(&c.name, &d.name)) { From 132257912a0159ab5d8cbde2d9a6b901f472acb9 Mon Sep 17 00:00:00 2001 From: Tanish Manku Date: Fri, 16 Oct 2026 17:52:24 -0700 Subject: [PATCH 56/85] README: put Community packages after the Get started status note (follow-up to #1149) (#6886) The Community packages heading from #1149 landed before the maintainers' releasing note and the IMPORTANT status admonition, so those two paragraphs rendered under Community packages instead of Get started. Move the section to just above Documentation; the content is unchanged. --- README.md | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/README.md b/README.md index 2ab84f644b..973fb5330d 100644 --- a/README.md +++ b/README.md @@ -295,6 +295,11 @@ tar -xzf photocraft--freebsd-x86_64.tar.gz --strip-components 1 -C /usr photocraft ``` +Maintainers: [`docs/releasing.md`](docs/releasing.md) explains how releases are built, signed and published. + +> [!IMPORTANT] +> **Status:** PhotoCraft is in early alpha, and we want to be straight about where it stands: much of Photoshop's feature surface exists in some form, but **it is not yet a Photoshop replacement for daily professional work**. The biggest gaps are AI/generative features, 15 missing tools, depth in typography and pro workflows, and plug-in compatibility. Every Photoshop menu item is wired to a command ([`docs/parity-checklist.md`](docs/parity-checklist.md)), but that measures wiring, not behaviour. The honest, dimension-by-dimension picture and where we're going next are in the [roadmap](ROADMAP.md) and [parity assessment](docs/target-app-parity.md). Expect rough edges, and please file issues (include your OS, document size, layer count and a screenshot). You can also tell us what broke on [Discord](https://discord.gg/artcraft). + ## Community packages These installers are maintained by community members, not by the PhotoCraft team. @@ -308,11 +313,6 @@ echo 'media-gfx/photocraft-bin ~amd64' >> /etc/portage/package.accept_keywords/p emerge --ask media-gfx/photocraft-bin ``` -Maintainers: [`docs/releasing.md`](docs/releasing.md) explains how releases are built, signed and published. - -> [!IMPORTANT] -> **Status:** PhotoCraft is in early alpha, and we want to be straight about where it stands: much of Photoshop's feature surface exists in some form, but **it is not yet a Photoshop replacement for daily professional work**. The biggest gaps are AI/generative features, 15 missing tools, depth in typography and pro workflows, and plug-in compatibility. Every Photoshop menu item is wired to a command ([`docs/parity-checklist.md`](docs/parity-checklist.md)), but that measures wiring, not behaviour. The honest, dimension-by-dimension picture and where we're going next are in the [roadmap](ROADMAP.md) and [parity assessment](docs/target-app-parity.md). Expect rough edges, and please file issues (include your OS, document size, layer count and a screenshot). You can also tell us what broke on [Discord](https://discord.gg/artcraft). - ## Documentation Developer, architecture, automation, format, and security documentation is maintained in the [PhotoCraft documentation book](book/). From a05daee325a15833c8d6368b92df8673e2ab980b Mon Sep 17 00:00:00 2001 From: F4LCON <107556180+itsF4LCON@users.noreply.github.com> Date: Sat, 17 Oct 2026 03:14:50 +0200 Subject: [PATCH 57/85] Filters: the live preview is never coarser than the screen (#6326) (#6351) On a document over about 5 MP with Low Resolution Previews on, a filter dialog's live preview ran on a reduced proxy at every zoom, so at 100 % the canvas showed the image in k-pixel blocks and hid exactly what Unsharp Mask, High Pass and Smart Blur do. ensure_filter_preview now caps its factor with proxy::view_factor(k, zoom), the same rule adjustment previews got in #5764, so from 100 % the preview is full resolution and stays reduced when zoomed out. The preview already runs on a cancellable background worker, so the full-resolution path does not block the UI thread. Also in this change: - canvas.rs and filter_preview_ui.rs share one canvas::filter_preview_factor, so the in-dialog preview window cannot size textures from an uncapped factor and rebuild them when the first result arrives. - The cap is gated on cfg!(target_arch = "wasm32"), so the web build keeps its proxy rather than running a full-resolution filter inline on the UI thread. - The end-to-end test moved into mod tests and covers Low Resolution Previews off; a new factor test covers 25/50/75/100/400 %, NaN and zero zoom. --- crates/ui-egui/src/canvas.rs | 77 +++++++++++++++++++++++- crates/ui-egui/src/filter_preview_ui.rs | 7 ++- crates/ui-egui/tests/full_res_preview.rs | 19 ++++-- 3 files changed, 95 insertions(+), 8 deletions(-) diff --git a/crates/ui-egui/src/canvas.rs b/crates/ui-egui/src/canvas.rs index 38c3b439b5..914d395acc 100644 --- a/crates/ui-egui/src/canvas.rs +++ b/crates/ui-egui/src/canvas.rs @@ -1720,6 +1720,27 @@ fn budget_allowance(app: &PhotocraftApp) -> (u64, u64) { (u64::from(performance.memory_usage_mb).saturating_mul(1 << 20), u64::from(performance.gpu_memory_mb).saturating_mul(1 << 20)) } +/// The proxy factor of a live preview of filter `cmd` with `params` on `doc`, the active document: +/// reduced on a large document (Preferences › Performance › Low Resolution Previews), except for +/// filters whose features don't scale with a proxy (#2063), and never coarser than the active view, +/// so at 100 % the preview shows every pixel instead of k-pixel blocks (#6326, as adjustment +/// previews have since #5764). Zoomed out, the proxy keeps previews of large documents fast. +/// +/// The in-dialog preview window ([`crate::filter_preview_ui`]) sizes its own textures from this +/// too, so the canvas and the window never disagree about the factor. +/// +/// Full resolution costs a whole filter per parameter change, which only the background worker +/// absorbs. The web build has no worker and computes the preview inline on the UI thread (#1676), +/// where that would freeze the page: there the document's proxy stands. The test and +/// `PHOTOCRAFT_INLINE_JOBS` inline paths keep the cap deliberately, because `background_jobs` is +/// flipped mid-session (a shell save during `actions.play`): a factor that followed it would change +/// [`crate::filter_dialog::FilterPreviewKey`] under a finished preview and recompute it inline, +/// the very stall this avoids. +pub(crate) fn filter_preview_factor(app: &PhotocraftApp, doc: &Document, cmd: &str, params: &serde_json::Value) -> u32 { + let k = crate::filter_dialog::preview_factor(cmd, params, crate::proxy::preview_factor(doc, crate::proxy::reduced_previews(app))); + if cfg!(target_arch = "wasm32") { k } else { crate::proxy::view_factor(k, app.current_zoom()) } +} + /// Live preview for an open filter dialog: run the filter on the proxy and upload it. With /// background jobs (the desktop app) it computes on one background worker /// (`filter_preview_worker`); otherwise (web, tests, `PHOTOCRAFT_INLINE_JOBS`) it keeps the @@ -1751,8 +1772,7 @@ pub(crate) fn ensure_filter_preview(app: &mut PhotocraftApp, idx: usize, ctx: &e params = crate::select_mask_view::request_params(&d.fields, params); } let st = app.session.documents().get(idx)?; - // Filters whose features don't scale with a proxy preview at full resolution (#2063). - let k = crate::filter_dialog::preview_factor(&cmd, ¶ms, crate::proxy::preview_factor(&st.doc, crate::proxy::reduced_previews(app))); + let k = filter_preview_factor(app, &st.doc, &cmd, ¶ms); let request = crate::filter_dialog::FilterPreviewKey { doc: st.doc.id, revision: st.revision, @@ -6108,6 +6128,59 @@ mod tests { assert_eq!(super::display_image(Some(&d), &buf).pixels, reference(d.to_rgba8(&buf)).pixels); } + /// #6326: a filter's live preview is never coarser than the screen. On a document big enough + /// for a reduced proxy, the view at 100 % previews at full resolution, and only a view zoomed + /// out far enough uses the proxy. Through `ensure_filter_preview`, so the factor the canvas + /// actually requests is the one under test. + #[test] + fn filter_preview_at_100_percent_is_full_resolution() { + let mut app = PhotocraftApp::new(photocraft_engine::Session::new(), crate::Services::default()); + app.background_jobs = false; + app.run("file.new", json!({"width": 2400, "height": 2200})).unwrap(); + assert_eq!(crate::proxy::preview_factor(&app.session.active().unwrap().doc, true), 2, "big enough for a proxy"); + crate::filter_dialog::open(&mut app, "filter.stylize.solarize").unwrap(); + let ctx = egui::Context::default(); + let preview_at = |app: &mut PhotocraftApp, zoom: f32| { + app.ui.views.get_mut(0).expect("the new document's view").zoom = zoom; + assert_eq!(app.current_zoom(), zoom); + let (k, _, size) = ensure_filter_preview(app, 0, &ctx).expect("a preview"); + (k, size) + }; + assert_eq!(preview_at(&mut app, 1.0), (1, [2400, 2200]), "100 %: every pixel"); + assert_eq!(preview_at(&mut app, 0.25), (2, [1200, 1100]), "zoomed out: the document's proxy"); + // Low Resolution Previews off: full resolution at any zoom, exactly as before #6326. + app.run("prefs.set", json!({"path": "performance.lowResolutionPreviews", "value": false})).unwrap(); + assert_eq!(preview_at(&mut app, 0.25), (1, [2400, 2200]), "the preference still decides"); + } + + /// The factor itself over the zoom range, and that Preferences › Performance › Low + /// Resolution Previews still decides whether there is a proxy at all (#6326). + #[test] + fn filter_preview_factor_caps_at_the_view_without_overriding_the_preference() { + let mut app = PhotocraftApp::new(photocraft_engine::Session::new(), crate::Services::default()); + // 9 MP: reduced previews halve it (empty tiles, so nothing is allocated). + app.run("file.new", json!({"width": 3000, "height": 3000, "background": "transparent"})).unwrap(); + let doc = app.session.active().unwrap().doc.clone(); + let params = json!({"amount": 100, "radius": 4.0, "threshold": 0}); + let k = |app: &mut PhotocraftApp, zoom: f32| { + app.ui.views.get_mut(0).expect("the new document's view").zoom = zoom; + super::filter_preview_factor(app, &doc, "filter.sharpen.unsharpMask", ¶ms) + }; + assert_eq!(k(&mut app, 0.25), 2, "zoomed out the preview stays reduced"); + assert_eq!(k(&mut app, 0.5), 2); + assert_eq!(k(&mut app, 0.75), 1, "past the proxy's resolution it shows every pixel"); + assert_eq!(k(&mut app, 1.0), 1); + assert_eq!(k(&mut app, 4.0), 1); + // A corrupt or unset zoom must not pick a coarser proxy than the document's own. + assert_eq!(k(&mut app, f32::NAN), 1); + assert_eq!(k(&mut app, 0.0), 1); + // Low Resolution Previews off: full resolution at any zoom, the cap a no-op. + app.run("prefs.set", json!({"path": "performance.lowResolutionPreviews", "value": false})).unwrap(); + for zoom in [0.1, 0.25, 1.0, 4.0] { + assert_eq!(k(&mut app, zoom), 1, "zoom {zoom}"); + } + } + #[cfg(not(target_arch = "wasm32"))] #[test] fn every_filter_previews_off_the_ui_thread_and_a_newer_value_cancels_the_old() { diff --git a/crates/ui-egui/src/filter_preview_ui.rs b/crates/ui-egui/src/filter_preview_ui.rs index 90eaf4f6fe..cf290cf190 100644 --- a/crates/ui-egui/src/filter_preview_ui.rs +++ b/crates/ui-egui/src/filter_preview_ui.rs @@ -46,9 +46,14 @@ pub(crate) fn window(app: &PhotocraftApp, dialog: u64, ui: &mut egui::Ui, f: &mu (preview_on && p.key.dialog == dialog && p.key.doc == doc_id && p.key.command == cmd).then(|| p.result.clone().map(|r| (p.key.clone(), r))).flatten() }); // The proxy factor the preview runs at (the filtered key's when it exists, else the request's). + // The fallback must be the factor the canvas will ask for, not the document's own: since #6326 + // the canvas caps it with the view's zoom, and a window that sized its textures from the + // uncapped factor built a proxy the canvas never asked for, then rebuilt it (a full-size copy + // and upload on the UI thread at 100 %) the moment the first result arrived, shifting this + // window's 100 % mapping and pan clamp for a frame. let base_k = filtered.as_ref().map(|(k, _)| k.k).unwrap_or_else(|| { let params = app.with_mask_target(&cmd, crate::filter_dialog::params_of(f)); - crate::filter_dialog::preview_factor(&cmd, ¶ms, crate::proxy::preview_factor(&st.doc, crate::proxy::reduced_previews(app))) + crate::canvas::filter_preview_factor(app, &st.doc, &cmd, ¶ms) }); let t = Tokens::get(ui.ctx()); diff --git a/crates/ui-egui/tests/full_res_preview.rs b/crates/ui-egui/tests/full_res_preview.rs index 191a071b9d..f472dd2ad3 100644 --- a/crates/ui-egui/tests/full_res_preview.rs +++ b/crates/ui-egui/tests/full_res_preview.rs @@ -145,21 +145,30 @@ fn adjustment_layer_drag_previews_at_full_resolution_when_low_res_is_off() { assert!((full - committed).abs() < 8.0, "low-res previews off: the drag matches the committed view ({full} vs {committed})"); } +/// A filter dialog's live preview is never coarser than the screen either (#6326): at 100 % every +/// column shows with low-resolution previews on; zoomed out to 50 % the default preview runs on +/// the reduced copy (only the black columns), and with them off it matches the committed view. #[test] fn filter_dialog_previews_at_full_resolution_when_low_res_is_off() { let _gpu = photocraft_testkit::gpu_lock(); let Some(mut h) = harness() else { return }; - setup(&mut h, 1.0); + setup(&mut h, 0.5); if !on_gpu(&mut h) { return; } + let committed = mean(&row(&mut h)); + assert!(committed > 60.0, "zoomed out the columns average to grey ({committed})"); + control(&mut h, "ui.set", json!({"zoom": 1.0, "center": [W / 2, H / 2]})); // Offset 0, 0 leaves the pixels as they are: any change is the preview's resolution. photocraft_ui_egui::filter_dialog::open(h.state_mut(), "filter.other.offset").expect("Offset dialog"); - let reduced = row(&mut h); - assert!(columns(&reduced) < 0.1, "default: the reduced copy loses the columns ({})", columns(&reduced)); + let at_100 = row(&mut h); + assert!(columns(&at_100) > 0.9, "default at 100 %: the preview is never coarser than the screen ({})", columns(&at_100)); + control(&mut h, "ui.set", json!({"zoom": 0.5, "center": [W / 2, H / 2]})); + let reduced = mean(&row(&mut h)); + assert!(reduced < 30.0, "default at 50 %: the reduced copy keeps only the black columns ({reduced})"); low_res(&mut h, false); - let full = row(&mut h); - assert!(columns(&full) > 0.9, "low-res previews off: every column shows ({})", columns(&full)); + let full = mean(&row(&mut h)); + assert!((full - committed).abs() < 8.0, "low-res previews off: the preview matches the committed view ({full} vs {committed})"); } #[test] From 4e5c6a018ac54a14ae4443377ee132f179036ea4 Mon Sep 17 00:00:00 2001 From: Ahmed Magdy <110637215+ahmed0magdy@users.noreply.github.com> Date: Sat, 17 Oct 2026 04:19:47 +0300 Subject: [PATCH 58/85] fix(io): SVG text placed by a transform keeps it on import (#4806) (#5334) usvg builds the glyph outlines of a text element (Text::flattened) with identity absolute transforms, and the SVG importer read each path's abs_transform(). Text positioned by its own or an ancestor's transform, as Illustrator writes every text element (matrix(1 0 0 1 x y)), lost it: it landed off the canvas, so nothing showed, or at its untransformed place and size. The converter now composes each node's transform from its parent chain, which equals abs_transform() outside text, and sizes raster fallbacks from the same transform. A new test checks translate, matrix, scale and transformed groups against resvg's own render; it fails on main. Co-authored-by: Claude Opus 5.5 --- crates/io/src/svg.rs | 38 ++++++++++++++++++++++--------- crates/io/tests/svg.rs | 51 ++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 78 insertions(+), 11 deletions(-) diff --git a/crates/io/src/svg.rs b/crates/io/src/svg.rs index 04fc4bee3a..72b2318675 100644 --- a/crates/io/src/svg.rs +++ b/crates/io/src/svg.rs @@ -337,6 +337,11 @@ impl Conv { /// `nodes` in paint order (first = bottom), as layers in the same order. `parent` is the /// absolute transform of the group holding them (what resvg needs to render one of them /// alone, since a node's own data is in its parent's space). + /// + /// Transforms are composed from `parent` down rather than read from `abs_transform()`: the + /// glyph outlines usvg builds for a text element (`Text::flattened`) keep identity absolute + /// transforms, so text placed by its own or an ancestor's `transform` (Illustrator writes + /// `matrix(1 0 0 1 x y)` on every text element) would lose it (#4806). Elsewhere the two agree. fn convert_children(&mut self, nodes: &[usvg::Node], depth: usize, rec: usize, parent: tiny_skia::Transform) -> Vec { let mut out = Vec::new(); for node in nodes { @@ -350,7 +355,7 @@ impl Conv { usvg::Node::Group(g) => { // A clip-path of one plain path becomes the group's vector mask (#4266); others // are rasterised with the group. - let clip = g.clip_path().and_then(|cp| self.clip_mask(g, cp)); + let clip = g.clip_path().and_then(|cp| self.clip_mask(parent.pre_concat(g.transform()), cp)); let dropped: [(bool, &'static str); 3] = [(!g.filters().is_empty(), "filter"), (g.clip_path().is_some() && clip.is_none(), "clip-path"), (g.mask().is_some(), "mask")]; let too_deep = self.max_group_depth.is_some_and(|limit| depth >= limit) || depth >= MAX_GROUP_DEPTH || rec >= MAX_RECURSION; @@ -370,7 +375,8 @@ impl Conv { // A group that only carries a transform (baked into its paths by usvg) or that // usvg inserted for its own bookkeeping adds nothing to the layer tree. let plain = clip.is_none() && g.id().is_empty() && g.opacity().get() >= 1.0 && g.blend_mode() == usvg::BlendMode::Normal; - let children = self.convert_children(g.children(), if plain { depth } else { depth + 1 }, rec + 1, g.abs_transform()); + let abs = parent.pre_concat(g.transform()); + let children = self.convert_children(g.children(), if plain { depth } else { depth + 1 }, rec + 1, abs); if children.is_empty() { return; } @@ -404,7 +410,9 @@ impl Conv { out.extend(self.raster_layer(node, name, parent)); } usvg::Node::Text(t) => { - let children = self.convert_children(t.flattened().children(), depth + 1, rec + 1, t.flattened().abs_transform()); + // The outlines are in the text element's own space. + let abs = t.abs_transform().pre_concat(t.flattened().transform()); + let children = self.convert_children(t.flattened().children(), depth + 1, rec + 1, abs); if children.is_empty() { self.note("text", "SVG: text needs a font the drawing can use; none matched, so it was dropped".to_string()); return; @@ -421,10 +429,10 @@ impl Conv { } } - /// The vector mask for group `g` clipped by `cp`, when the clip is one visible path with no - /// clip of its own: what resvg draws for the clip (the group's transform, then the clip's, - /// then the path), in canvas pixels, with the clip rule as its fill rule. - fn clip_mask(&self, g: &usvg::Group, cp: &usvg::ClipPath) -> Option { + /// The vector mask for a group clipped by `cp`, when the clip is one visible path with no + /// clip of its own: what resvg draws for the clip (the group's absolute transform `abs`, then + /// the clip's, then the path), in canvas pixels, with the clip rule as its fill rule. + fn clip_mask(&self, abs: tiny_skia::Transform, cp: &usvg::ClipPath) -> Option { if cp.clip_path().is_some() { return None; } @@ -432,7 +440,7 @@ impl Conv { if !p.is_visible() { return None; } - let ts = g.abs_transform().pre_concat(cp.transform()).post_concat(self.global); + let ts = abs.pre_concat(cp.transform()).post_concat(self.global); let subpaths = knots(&(*p.data()).clone().transform(ts)?); if subpaths.is_empty() { return None; @@ -455,7 +463,8 @@ impl Conv { let name = self.name(p.id(), 0); return self.raster_layer(node, name, parent); } - let ts = p.abs_transform().post_concat(self.global); + // A path has no transform of its own: its data is in its parent's space. + let ts = parent.post_concat(self.global); let data = (*p.data()).clone().transform(ts)?; let subpaths = knots(&data); if subpaths.is_empty() { @@ -542,7 +551,14 @@ impl Conv { /// The node rendered by resvg into a pixel layer over its bounding box, clipped to the canvas. /// `parent` is the absolute transform of the node's parent group. fn raster_layer(&mut self, node: &usvg::Node, name: String, parent: tiny_skia::Transform) -> Option { - let bb = node.abs_layer_bounding_box()?; + // Where the node lands, from its box in its parent's space (as usvg computes its absolute + // box, but through `parent`, which is right inside text too). + let bb = match node { + usvg::Node::Group(g) => g.layer_bounding_box().transform(parent.pre_concat(g.transform()))?, + _ => node.bounding_box().to_non_zero_rect()?.transform(parent)?, + }; + // What resvg shifts the node by before drawing it. + let shift = node.abs_layer_bounding_box()?; let k = self.global.sx; let x0 = ((bb.x() * k).floor() as i32).max(self.canvas.x0); let y0 = ((bb.y() * k).floor() as i32).max(self.canvas.y0); @@ -559,7 +575,7 @@ impl Conv { // to the origin: undo that shift, apply the parent's transform, then scale to the canvas // and shift so the pixmap starts at (x0, y0). let canvas = tiny_skia::Transform::from_row(k, 0.0, 0.0, k, -(x0 as f32), -(y0 as f32)); - let ts = canvas.pre_concat(parent).pre_translate(bb.x(), bb.y()); + let ts = canvas.pre_concat(parent).pre_translate(shift.x(), shift.y()); resvg::render_node(node, ts, &mut pixmap.as_mut())?; Some(Layer::new(name, LayerContent::Raster(surface(&pixmap, Rect::new(x0, y0, x1, y1), self.fmt)))) } diff --git a/crates/io/tests/svg.rs b/crates/io/tests/svg.rs index d1baf418fa..4f21bd5dcb 100644 --- a/crates/io/tests/svg.rs +++ b/crates/io/tests/svg.rs @@ -221,6 +221,57 @@ fn text_becomes_outline_shapes_or_warns_without_a_font() { assert!(dark > 4, "glyph strokes cross the middle row: {dark}"); } +/// #4806: text placed by a `transform` (Illustrator writes `matrix(1 0 0 1 x y)` on every text +/// element), on the text or on a group around it, lands where resvg draws it. usvg leaves the +/// glyph outlines' absolute transforms at identity, so reading them dropped the transform: the +/// text went off the canvas or kept its untransformed place and size. +#[test] +fn transformed_text_lands_where_resvg_draws_it() { + let font = "font-size=\"24\" font-family=\"DejaVu Sans, Arial, Helvetica, sans-serif\" fill=\"black\""; + let cases = [ + format!("IHI"), + format!("IHI"), + format!("IHI"), + format!("IHI"), + format!("IHI"), + format!("IHI"), + ]; + for c in cases { + let svg = format!("{c}"); + let r = open(&svg); + let d = &r.document; + if d.layers.is_empty() { + // No font at all on this machine: the import says so instead. + assert!(r.warnings.iter().any(|w| w.contains("font")), "{:?}", r.warnings); + return; + } + assert!(matches!(d.layers[0].content, LayerContent::Group(_)), "text stays outline shapes: {c}"); + let imported = photocraft_compose::flatten(d); + let reference = svg::rasterize(svg.as_bytes(), 1.0).unwrap(); + let (mut both, mut either) = (0usize, 0usize); + let (mut a_box, mut b_box) = ([i32::MAX, i32::MAX, i32::MIN, i32::MIN], [i32::MAX, i32::MAX, i32::MIN, i32::MIN]); + let grow = |bx: &mut [i32; 4], x: i32, y: i32| *bx = [bx[0].min(x), bx[1].min(y), bx[2].max(x), bx[3].max(y)]; + for y in 0..60 { + for x in 0..120 { + let (a, b) = (imported.get(x, y)[3] > 0.5, reference.get(x, y)[3] > 0.5); + if a { + grow(&mut a_box, x, y); + } + if b { + grow(&mut b_box, x, y); + } + both += usize::from(a && b); + either += usize::from(a || b); + } + } + assert!(either > 50, "resvg draws the text on the canvas: {c}"); + // Same place and size to the pixel; the outlines are filled by a different rasteriser, so + // the edges of thin stems may differ by half a pixel. + assert!(a_box.iter().zip(b_box).all(|(a, b)| (a - b).abs() <= 1), "imported {a_box:?}, resvg {b_box:?}: {c}"); + assert!(both * 10 >= either * 7, "imported glyphs cover what resvg draws ({both} of {either} px): {c}"); + } +} + #[test] fn size_comes_from_the_viewbox_and_huge_drawings_shrink_to_fit() { let r = open(&format!("")); From d213273c744cd34787866e1b76d6bf5700353f2f Mon Sep 17 00:00:00 2001 From: Matthew Santiago Date: Fri, 16 Oct 2026 20:19:53 -0500 Subject: [PATCH 59/85] GPU composite: lower-stack cache chunks have no texture view (#5423) The prefix cache's chunk textures are only copied to and from (COPY_SRC | COPY_DST), but went through Tex::new, which always creates a default view. Vulkan rejects a view on an image with no sampled, storage or attachment usage (VUID-VkImageViewCreateInfo-image-04441); wgpu doesn't check it, so it only shows with the validation layers on, once per new chunk while painting. The view was never used. Chunks now hold a bare wgpu::Texture. No rendering change. Co-authored-by: Claude Opus 5.5 --- crates/gpu/src/prefix.rs | 21 +++++++++++++++------ 1 file changed, 15 insertions(+), 6 deletions(-) diff --git a/crates/gpu/src/prefix.rs b/crates/gpu/src/prefix.rs index 34c70916e0..cf53a62850 100644 --- a/crates/gpu/src/prefix.rs +++ b/crates/gpu/src/prefix.rs @@ -18,15 +18,16 @@ use std::collections::HashMap; use photocraft_doc::{Document, Layer}; use photocraft_geom::Rect; -use crate::Tex; use crate::plan::{Checkpoint, Plan}; pub(crate) const MAX_BYTES: u64 = 512 << 20; /// One whole grid chunk, with the document rectangles that hold valid pixels: a whole-chunk /// capture covers the cell in one entry, partial captures accumulate alongside each other. +/// The texture is only copied to and from, so it has no view: Vulkan rejects a view on an image +/// without a sampled/storage/attachment usage (VUID-VkImageViewCreateInfo-image-04441). pub(crate) struct Chunk { - tex: Tex, + tex: wgpu::Texture, covered: Vec, /// The checkpoint the covered pixels hold. checkpoint: Checkpoint, @@ -172,7 +173,7 @@ impl Prefix { let Some((chunk, (ox, oy))) = self.covered_by(rect) else { return false }; encoder.copy_texture_to_texture( wgpu::TexelCopyTextureInfo { - texture: &chunk.tex.texture, + texture: &chunk.tex, mip_level: 0, origin: wgpu::Origin3d { x: ox.unsigned_abs(), y: oy.unsigned_abs(), z: 0 }, aspect: wgpu::TextureAspect::All, @@ -198,8 +199,16 @@ impl Prefix { if cell_bytes == 0 || cell_bytes > self.budget.saturating_sub(self.bytes) { return; } - let tex = - Tex::new(device, "pc_compose_prefix", cell.width(), cell.height(), src.format(), wgpu::TextureUsages::COPY_SRC | wgpu::TextureUsages::COPY_DST); + let tex = device.create_texture(&wgpu::TextureDescriptor { + label: Some("pc_compose_prefix"), + size: wgpu::Extent3d { width: cell.width().max(1), height: cell.height().max(1), depth_or_array_layers: 1 }, + mip_level_count: 1, + sample_count: 1, + dimension: wgpu::TextureDimension::D2, + format: src.format(), + usage: wgpu::TextureUsages::COPY_SRC | wgpu::TextureUsages::COPY_DST, + view_formats: &[], + }); self.bytes = self.bytes.saturating_add(cell_bytes); self.chunks.insert(cell, Chunk { tex, covered: Vec::new(), checkpoint, bytes: cell_bytes }); } @@ -221,7 +230,7 @@ impl Prefix { encoder.copy_texture_to_texture( wgpu::TexelCopyTextureInfo { texture: src, mip_level: 0, origin: wgpu::Origin3d::ZERO, aspect: wgpu::TextureAspect::All }, wgpu::TexelCopyTextureInfo { - texture: &chunk.tex.texture, + texture: &chunk.tex, mip_level: 0, origin: wgpu::Origin3d { x: ox.unsigned_abs(), y: oy.unsigned_abs(), z: 0 }, aspect: wgpu::TextureAspect::All, From a1e19a0b878c1bb4bc0609e1bd7db56f3688512c Mon Sep 17 00:00:00 2001 From: nrq6993-a11y Date: Sat, 17 Oct 2026 04:19:59 +0300 Subject: [PATCH 60/85] fix(text): break paragraph lines at the horizontally scaled width (#5611) Horizontal scale was applied after line breaking, so paragraph text below 100% wrapped early and text above 100% overflowed the box. Break box text at the available width divided by the paragraph's horizontal scale (the largest one when runs mix), scale the first-line indent and alignment offset back to the box, and keep the scaled trailing space out of the line extent. Fixes storytold/photocraft#5565 Co-authored-by: Cursor --- crates/text/src/layout.rs | 48 +++++++++++++++++++++++++++++++++------ crates/text/src/tests.rs | 38 +++++++++++++++++++++++++++++++ 2 files changed, 79 insertions(+), 7 deletions(-) diff --git a/crates/text/src/layout.rs b/crates/text/src/layout.rs index 19d7b8c72d..ae1bd552af 100644 --- a/crates/text/src/layout.rs +++ b/crates/text/src/layout.rs @@ -442,6 +442,8 @@ struct KernSlot { blank: bool, /// Advance along the line (px, horizontal scale included). advance: f32, + /// What horizontal scale adds to the shaped advance (px; negative below 100%). + growth: f32, /// A tab, shaped as a space; it advances to the next tab stop. tab: bool, /// Vertical type, upright glyph (its outline doesn't run along the column). @@ -646,11 +648,29 @@ impl Layouter { // Box extent along the lines: width, or height for vertical type (columns). let (line_origin, line_len) = if vertical { (box_rect.1, box_rect.3) } else { (box_rect.0, box_rect.2) }; let avail = if is_box { Some((line_len - indent_start - indent_end).max(1.0)) } else { None }; + // Horizontal scale is applied after shaping, so paragraph text breaks at the box + // width divided by it (#5565). Mixed scales break at the largest, so no line + // overflows the box. + let break_scale = if is_box { + let si0 = style_at(prange.start); + (0..out.styles.len()) + .filter(|&ri| { + let rs = run_starts.get(ri).copied().unwrap_or(0); + let end = rs.saturating_add(runs.get(ri).map_or(0, |r| r.len)); + ri == si0 || (rs.max(prange.start) < end.min(content_end)) + }) + .filter_map(|ri| out.styles.get(ri)) + .map(effective_horizontal_scale) + .fold(0.0f32, f32::max) + } else { + 1.0 + }; + let break_scale = if break_scale > 0.0 { break_scale } else { 1.0 }; let break_lines = |layout: &mut Layout| { if first_indent != 0.0 { - layout.set_text_indent(first_indent, IndentOptions::default()); + layout.set_text_indent(first_indent / break_scale, IndentOptions::default()); } - layout.break_all_lines(avail); + layout.break_all_lines(avail.map(|a| a / break_scale)); }; // Paragraph text: a tab is never wider than a space plus one tab interval, so breaking // lines at that width keeps the expanded tabs inside the box, but can wrap early. @@ -779,6 +799,7 @@ impl Layouter { blank: first.is_none() || c.is_space_or_nbsp() || c.text_range().end <= prefix.len(), upright: vertical && c.first_style().brush.1 != VClass::Rotate as u8, advance: c.advance() * hs, + growth: c.advance() * (hs - 1.0), tab: tabs.binary_search(&c.text_range().start).is_ok(), }); } @@ -823,10 +844,15 @@ impl Layouter { // moves the whole line). let mut pos = indent_start + if li == 0 { first_indent } else { 0.0 }; let mut trailing_tabs = 0.0f32; + let mut trailing_growth = 0.0f32; for (j, s) in slots.iter().enumerate() { + // Trailing whitespace is outside the line's extent (`adv`). + let trailing = s.blank && slots[j + 1..].iter().all(|n| n.blank); + if trailing { + trailing_growth += s.growth; + } if s.tab { kern_px[j] = next_tab_stop(pos, tab_px) - pos - s.advance; - // Trailing whitespace is outside the line's extent (`adv`). if slots[j + 1..].iter().all(|n| n.blank) { trailing_tabs += kern_px[j]; } @@ -864,15 +890,18 @@ impl Layouter { }; let dx = if is_box { let base = line_origin + indent_start - kern_align; - let slack = avail.unwrap_or(0.0) - adv - line_kern; + let slack = avail.unwrap_or(0.0) - adv * break_scale - line_kern; + // Parley aligned the unscaled line in `avail / break_scale`: scale its + // indent and alignment offset back to the box. + let scaled_offset = m.offset * (break_scale - 1.0); base + if last_line { match ps.align { TextAlign::JustifyCenter => slack * 0.5 - m.offset, TextAlign::JustifyRight => slack - m.offset, - _ => 0.0, + _ => scaled_offset, } } else { - 0.0 + scaled_offset } } else { let target = match ps.align { @@ -1037,7 +1066,7 @@ impl Layouter { range: map(lr.start)..map(lr.end).min(content_end), baseline, x0, - x1: x0 + adv + extra - trailing_tabs, + x1: x0 + adv + extra - trailing_tabs - trailing_growth, ascent, descent, paragraph: pi, @@ -1049,6 +1078,11 @@ impl Layouter { } } +/// A style's horizontal scale; unset (zero or negative) is 100%. +fn effective_horizontal_scale(st: &CharStyle) -> f32 { + if st.horizontal_scale > 0.0 && st.horizontal_scale.is_finite() { st.horizontal_scale } else { 1.0 } +} + /// The first tab stop after `pos` (px from the anchor or box edge); stops are `interval` apart. fn next_tab_stop(pos: f32, interval: f32) -> f32 { if interval > 0.0 && pos.is_finite() { ((pos + 1e-3) / interval).floor() * interval + interval } else { pos } diff --git a/crates/text/src/tests.rs b/crates/text/src/tests.rs index 7afb4d6cd1..894ecf4e4f 100644 --- a/crates/text/src/tests.rs +++ b/crates/text/src/tests.rs @@ -272,6 +272,44 @@ fn box_text_wraps_inside_width() { assert_eq!(l2.lines.len(), 2); } +/// #5565: paragraph text breaks at its horizontally scaled width. At 60% it breaks like 100% +/// text in a box 1/0.6 as wide, and the lines fill the box instead of wrapping early. +#[test] +fn horizontal_scale_breaks_paragraph_lines_at_the_scaled_width() { + let mut e = TextEngine::new(); + let text = "The quick brown fox jumps over the lazy dog again and again until the paragraph wraps"; + let boxed = |scale: f32, width: f32, align: TextAlign| { + let style = CharStyle { font_family: "Inter".into(), size_pt: 12.0, horizontal_scale: scale, ..Default::default() }; + let t = TextLayer { shape: TextShape::Box { x: 10.0, y: 0.0, width, height: 1000.0 }, ..styled(text, style) }; + with_para(t, ParagraphStyle { align, ..Default::default() }) + }; + let ranges = |l: &crate::TextLayout| l.lines.iter().map(|ln| ln.range.clone()).collect::>(); + for scale in [0.6f32, 1.5] { + let scaled = e.layout(&boxed(scale, 120.0, TextAlign::Left), 72.0); + let plain = e.layout(&boxed(1.0, 120.0 / scale, TextAlign::Left), 72.0); + assert!(scaled.lines.len() > 1, "{scale}"); + assert_eq!(ranges(&scaled), ranges(&plain), "{scale}: same breaks as unscaled text in the widened box"); + for (li, ln) in scaled.lines.iter().enumerate() { + assert!(ln.x0 >= 10.0 - 1e-3 && ln.x1 <= 130.0 + 1e-3, "{scale}: {ln:?}"); + // The extent ends at the last glyph, not after the scaled trailing space. + let ink = scaled.clusters.iter().filter(|c| c.line == li && !text[c.range.clone()].trim().is_empty()); + assert!((ink.map(|c| c.x + c.advance).fold(0.0, f32::max) - ln.x1).abs() < 0.01, "{scale}: {ln:?}"); + } + } + // At 60% the lines use the box: the first would not fit at 100%. + let scaled = e.layout(&boxed(0.6, 120.0, TextAlign::Left), 72.0); + assert!(scaled.lines[0].x1 > 110.0, "{:?}", scaled.lines[0]); + // Centred and right-aligned lines are placed by their scaled width. + let c = e.layout(&boxed(0.6, 120.0, TextAlign::Center), 72.0); + for ln in &c.lines { + assert!(((ln.x0 + ln.x1) * 0.5 - 70.0).abs() < 0.5, "{ln:?}"); + } + let r = e.layout(&boxed(0.6, 120.0, TextAlign::Right), 72.0); + for ln in &r.lines { + assert!((ln.x1 - 130.0).abs() < 0.5, "{ln:?}"); + } +} + #[test] fn alignment_point_and_box() { let mut e = TextEngine::new(); From c61cf9f0bd668ded8308e3c802d5439e7d62e02f Mon Sep 17 00:00:00 2001 From: nrq6993-a11y Date: Sat, 17 Oct 2026 04:20:06 +0300 Subject: [PATCH 61/85] fix(engine): load each persisted preset set independently (#5616) One malformed set in the preferences file (gradients, swatches, styles...) made load_presets_json ignore the whole presets blob, so saved New Document presets vanished at every launch: the shell merges back only changed keys, leaving the damaged set on disk. Each set now loads on its own; null and malformed sets keep their defaults. Refs storytold/photocraft#5602 Co-authored-by: Cursor --- crates/engine/src/presets/mod.rs | 24 +++++++++++++++++++++--- crates/engine/src/presets/tests.rs | 26 ++++++++++++++++++++++++++ 2 files changed, 47 insertions(+), 3 deletions(-) diff --git a/crates/engine/src/presets/mod.rs b/crates/engine/src/presets/mod.rs index 6c722c62ef..03696291dd 100644 --- a/crates/engine/src/presets/mod.rs +++ b/crates/engine/src/presets/mod.rs @@ -168,10 +168,28 @@ impl PresetState { } impl Session { - /// Restore presets saved by [`PresetState::to_json`]. Missing sets keep their defaults; a - /// malformed blob is ignored (presets are never worth refusing to start over). + /// Restore presets saved by [`PresetState::to_json`]. Missing or malformed sets keep their + /// defaults (presets are never worth refusing to start over). Each set loads on its own: the + /// shell writes back only the keys that changed, so one damaged set would otherwise discard + /// every other set, saved document presets included, at each launch (#5602). pub fn load_presets_json(&mut self, v: Value) { - let Ok(p) = serde_json::from_value::(v) else { return }; + let Value::Object(mut m) = v else { return }; + fn take(m: &mut serde_json::Map, key: &str) -> Option { + m.remove(key).filter(|v| !v.is_null()).and_then(|v| serde_json::from_value(v).ok()) + } + let p = Persisted { + gradients: take(&mut m, "gradients"), + gradient: take(&mut m, "gradient"), + styles: take(&mut m, "styles"), + shapes: take(&mut m, "shapes"), + pattern_groups: take(&mut m, "pattern_groups"), + tool_presets: take(&mut m, "tool_presets"), + documents: take(&mut m, "documents"), + recent_documents: take(&mut m, "recent_documents"), + custom_shapes: take(&mut m, "custom_shapes"), + layer_defaults: take(&mut m, "layer_defaults"), + swatches: take(&mut m, "swatches"), + }; let st = &mut self.presets; if let Some(g) = p.gradients { st.gradients = g; diff --git a/crates/engine/src/presets/tests.rs b/crates/engine/src/presets/tests.rs index dcfc4e94d8..b7c0a3ede2 100644 --- a/crates/engine/src/presets/tests.rs +++ b/crates/engine/src/presets/tests.rs @@ -748,3 +748,29 @@ fn clone_source_errors_and_overlay() { assert_eq!(v["width"], json!(1000.0)); assert_eq!(v["rotation"], json!(-90.0)); } + +// ------------------------------------------------------------------ persistence + +/// #5602: one damaged preset set in the preferences file must not discard the saved New +/// Document presets (or any other set) at the next launch. +#[test] +fn a_damaged_preset_set_keeps_the_other_sets() { + let mut s = Session::new(); + let settings = json!({"width": 1600, "height": 900, "resolution": 144}); + s.execute("document.presets.save", json!({"name": "Banner", "settings": settings})).unwrap(); + let saved = s.presets.to_json(&s); + for key in ["gradients", "gradient", "styles", "shapes", "pattern_groups", "custom_shapes", "layer_defaults", "swatches"] { + let mut blob = saved.clone(); + blob[key] = json!("not a preset set"); + let mut next = Session::new(); + next.load_presets_json(blob); + assert_eq!(next.presets.documents, s.presets.documents, "damaged `{key}`"); + assert_eq!(next.presets.tool_presets.len(), Session::new().presets.tool_presets.len(), "damaged `{key}`"); + } + // Null sets keep their defaults, as before. + let mut next = Session::new(); + next.load_presets_json(json!({"tool_presets": null, "gradients": null, "documents": saved["documents"].clone()})); + assert_eq!(next.presets.tool_presets.len(), Session::new().presets.tool_presets.len()); + assert_eq!(next.presets.gradients.len(), Session::new().presets.gradients.len()); + assert_eq!(next.presets.documents, s.presets.documents); +} From 75978a72f04ec9b9cefcfcd5ac365d36d74cf8b9 Mon Sep 17 00:00:00 2001 From: F4LCON <107556180+itsF4LCON@users.noreply.github.com> Date: Sat, 17 Oct 2026 04:15:32 +0200 Subject: [PATCH 62/85] Targa: RLE packets end with their scanline (#6382) (#6386) The image crate's Targa encoder let run-length packets run on from the end of one row into the next. TGA 2.0 says a packet must not cross a scanline, and readers that decode a row at a time misread such files: Pillow fails on them with "buffer overrun", and texture tools and game engines reject them. Targa is now written by our own encoder (codecs::tga), as Photoshop writes it: RLE true colour or grayscale, every packet within its row, rows stored bottom-up (the origin readers that ignore the descriptor assume), the alpha bits in the descriptor and the TGA 2.0 footer. Reading stays with the image crate. --- crates/codecs/README.md | 2 +- crates/codecs/src/codecs/mod.rs | 1 + crates/codecs/src/codecs/tga.rs | 90 ++++++++++++++++++++++++++ crates/codecs/src/codecs/via_image.rs | 7 ++- crates/codecs/tests/tga.rs | 91 +++++++++++++++++++++++++++ 5 files changed, 189 insertions(+), 2 deletions(-) create mode 100644 crates/codecs/src/codecs/tga.rs create mode 100644 crates/codecs/tests/tga.rs diff --git a/crates/codecs/README.md b/crates/codecs/README.md index d4f7094894..46baf50f82 100644 --- a/crates/codecs/README.md +++ b/crates/codecs/README.md @@ -38,7 +38,7 @@ let out = encode(&img, Format::Tiff, &EncodeOptions::default())?; | WebP | yes | yes | U8 | RGB, RGBA | yes | yes | yes | yes | no | no | yes (VP8, when `webp_lossless` is off) | `image-webp` / built-in VP8 | | GIF | yes | yes | U8 | RGBA | 1-bit | no | no | no | no | no | yes (256-colour palette) | `image` | | BMP | yes | yes | U8 | RGB, RGBA | yes | no | no | no | no | no | no | `image` | -| TGA | yes | yes | U8 | Gray, GrayA, RGB, RGBA | yes | no | no | no | no | no | no | `image` | +| TGA | yes | yes | U8 | Gray, GrayA, RGB, RGBA | yes | no | no | no | no | no | no | `image` (read) / built in (RLE write, one scanline per packet) | | ICO | yes | yes | U8 | RGBA (at most 256×256) | yes | no | no | no | no | no | no | `image` | | DDS | yes | **no** | U8 | Gray, GrayA, RGB, RGBA | yes | no | no | no | no | no | n/a | built in | | Netpbm (PBM/PGM/PPM/PAM/PFM) | yes | yes | U8, U16, F32 (float: PFM gray/RGB only) | all six (PAM `CMYK`/`CMYK_ALPHA`) | yes (PAM) | no | no | no | no | no | no | built in | diff --git a/crates/codecs/src/codecs/mod.rs b/crates/codecs/src/codecs/mod.rs index afb7f96791..32e68b336a 100644 --- a/crates/codecs/src/codecs/mod.rs +++ b/crates/codecs/src/codecs/mod.rs @@ -10,6 +10,7 @@ pub(crate) mod jpeg; pub(crate) mod pcx; pub(crate) mod png; pub(crate) mod pnm; +pub(crate) mod tga; pub(crate) mod tiff; pub(crate) mod tiff_exif; pub(crate) mod tiff_ifd; diff --git a/crates/codecs/src/codecs/tga.rs b/crates/codecs/src/codecs/tga.rs new file mode 100644 index 0000000000..7b52533d7b --- /dev/null +++ b/crates/codecs/src/codecs/tga.rs @@ -0,0 +1,90 @@ +//! Targa writer. The `image` crate still reads Targa ([`super::via_image`]); writing is ours +//! because its run-length packets ran on from the end of one row into the next. TGA 2.0 says a +//! packet must not cross a scanline, and readers that decode a row at a time (Pillow, texture +//! converters, game engines) misread such files (#6382). +//! +//! Like Photoshop's Targa: RLE (types 10 and 11), rows stored bottom-up (the bottom-left origin +//! every reader assumes, also those that ignore the origin bits), alpha bits in the descriptor, +//! and the TGA 2.0 footer. + +use crate::Format; +use crate::error::CodecError; +use crate::image::{ChannelLayout, Image, SampleType}; + +/// Most pixels one packet holds (a 7-bit count, plus one). +const MAX_PACKET: usize = 128; + +/// The TGA 2.0 footer: no extension or developer area, then the signature. +const FOOTER: &[u8; 26] = b"\0\0\0\0\0\0\0\0TRUEVISION-XFILE.\0"; + +/// Encodes an 8-bit Gray, Gray+alpha, RGB or RGBA image as a run-length encoded Targa. +pub(crate) fn encode(img: &Image) -> Result, CodecError> { + let (w, h) = img.dimensions(); + if img.sample_type() != SampleType::U8 { + return Err(CodecError::encode(Format::Tga, format!("{:?} samples (Targa stores 8 bits)", img.sample_type()))); + } + let (bpp, image_type, alpha_bits) = match img.layout() { + ChannelLayout::Gray => (1, 11, 0), + ChannelLayout::GrayA => (2, 11, 8), + ChannelLayout::Rgb => (3, 10, 0), + ChannelLayout::Rgba => (4, 10, 8), + l => return Err(CodecError::encode(Format::Tga, format!("no Targa layout for {l:?}"))), + }; + let (Ok(w16), Ok(h16)) = (u16::try_from(w), u16::try_from(h)) else { + return Err(CodecError::encode(Format::Tga, format!("dimensions exceed 65535x65535: {w}x{h}"))); + }; + let row = w as usize * bpp; + let data = img.data(); + if data.len() != row * h as usize { + return Err(CodecError::InvalidImage("buffer size mismatch".into())); + } + let mut out = Vec::with_capacity(18 + data.len() + data.len() / MAX_PACKET + FOOTER.len()); + out.extend_from_slice(&[0, 0, image_type, 0, 0, 0, 0, 0, 0, 0, 0, 0]); + out.extend_from_slice(&w16.to_le_bytes()); + out.extend_from_slice(&h16.to_le_bytes()); + // Bits per pixel; the descriptor holds the alpha bits, origin bits 0 (bottom-left). + out.extend_from_slice(&[(bpp * 8) as u8, alpha_bits]); + let mut px = vec![0u8; row]; + for y in (0..h as usize).rev() { + px.copy_from_slice(&data[y * row..(y + 1) * row]); + // Targa stores colour as BGR. + if bpp >= 3 { + for p in px.chunks_exact_mut(bpp) { + p.swap(0, 2); + } + } + rle_row(&px, bpp, &mut out); + } + out.extend_from_slice(FOOTER); + Ok(out) +} + +/// One scanline as run-length packets that end with it: a run of two or more equal pixels is a +/// run packet, anything else goes out in raw packets. +fn rle_row(row: &[u8], bpp: usize, out: &mut Vec) { + let px: Vec<&[u8]> = row.chunks_exact(bpp).collect(); + let n = px.len(); + let mut i = 0; + while i < n { + let mut run = 1; + while i + run < n && run < MAX_PACKET && px[i + run] == px[i] { + run += 1; + } + if run >= 2 { + out.push(0x80 | (run - 1) as u8); + out.extend_from_slice(px[i]); + i += run; + continue; + } + // Raw until two equal pixels start a run, the packet is full or the row ends. The first + // pixel always goes in: it differs from the one after it, or it is the row's last. + let start = i; + while i < n && i - start < MAX_PACKET && !(i > start && i + 1 < n && px[i] == px[i + 1]) { + i += 1; + } + out.push((i - start - 1) as u8); + for p in &px[start..i] { + out.extend_from_slice(p); + } + } +} diff --git a/crates/codecs/src/codecs/via_image.rs b/crates/codecs/src/codecs/via_image.rs index 7e7eb76b2f..fd45f6a173 100644 --- a/crates/codecs/src/codecs/via_image.rs +++ b/crates/codecs/src/codecs/via_image.rs @@ -1,5 +1,6 @@ //! Formats delegated to the `image` crate (pure-Rust features only): -//! GIF, BMP, TGA, ICO, QOI, Radiance HDR, and AVIF encode (feature `avif`). +//! GIF, BMP, TGA, ICO, QOI, Radiance HDR, and AVIF encode (feature `avif`). Targa is written by +//! [`super::tga`] and only read here. use std::io::Cursor; @@ -163,6 +164,10 @@ pub(crate) fn encode(f: Format, src: &Image, plan: Plan, opts: &EncodeOptions) - return Err(CodecError::encode(f, format!("dimensions exceed {mw}x{mh}"))); } let img = src.converted(plan.layout, plan.sample); + // Targa is written by our encoder: the image crate's RLE packets cross scanlines (#6382). + if f == Format::Tga { + return super::tga::encode(&img); + } let dynimg = to_dynamic(&img)?; let mut cursor = Cursor::new(Vec::new()); match f { diff --git a/crates/codecs/tests/tga.rs b/crates/codecs/tests/tga.rs new file mode 100644 index 0000000000..279aa47f8b --- /dev/null +++ b/crates/codecs/tests/tga.rs @@ -0,0 +1,91 @@ +//! Targa writing (#6382): run-length packets end with their scanline, rows are stored bottom-up, +//! and the file reads back exactly. + +use photocraft_codecs::{ChannelLayout, EncodeOptions, Format, Image, decode_as, encode}; + +/// The packets of a run-length encoded Targa body, as (pixels, is a run), from just after the +/// 18-byte header (no image ID or colour map) up to `pixels` pixels. +fn packets(file: &[u8], bpp: usize, pixels: usize) -> Vec<(usize, bool)> { + let mut at = 18; + let mut seen = 0; + let mut out = Vec::new(); + while seen < pixels { + let head = file[at]; + let count = usize::from(head & 0x7f) + 1; + let run = head & 0x80 != 0; + at += 1 + if run { bpp } else { count * bpp }; + seen += count; + out.push((count, run)); + } + out +} + +/// A `w`×`h` image whose rows mix long runs (wider than a packet), short ones and noise, and +/// whose last pixel equals the next row's first, so a packet would run on if it could. +fn sample(w: u32, h: u32, layout: ChannelLayout) -> Image { + let n = layout.channels(); + let mut data = Vec::new(); + for y in 0..h { + for x in 0..w { + let v = match x { + _ if x < 150 => 40, + _ if x % 3 == 0 => (x * 7 + y * 13) as u8, + _ => 200, + }; + let px = [v, v.wrapping_add(y as u8), v ^ 0x5a, 255 - v]; + data.extend_from_slice(&px[..n]); + } + } + Image::from_u8(w, h, layout, data).unwrap() +} + +#[test] +fn rle_packets_end_with_their_scanline() { + for layout in [ChannelLayout::Gray, ChannelLayout::GrayA, ChannelLayout::Rgb, ChannelLayout::Rgba] { + let (w, h) = (300u32, 4u32); + let file = encode(&sample(w, h, layout), Format::Tga, &EncodeOptions::default()).unwrap(); + let bpp = layout.channels(); + let mut col = 0; + for (count, _) in packets(&file, bpp, (w * h) as usize) { + assert!(count <= 128, "{layout:?}: a packet holds at most 128 pixels"); + assert!(col + count <= w as usize, "{layout:?}: a packet runs from column {col} past the row's end"); + col = (col + count) % w as usize; + } + // A solid image is one run per row (three packets of 128, 128 and 44), not one long run. + let solid = Image::from_u8(w, h, layout, vec![90; (w * h) as usize * bpp]).unwrap(); + let file = encode(&solid, Format::Tga, &EncodeOptions::default()).unwrap(); + let p = packets(&file, bpp, (w * h) as usize); + assert_eq!(p.len(), 3 * h as usize, "{layout:?}"); + assert!(p.iter().all(|&(_, run)| run)); + } +} + +#[test] +fn the_file_is_bottom_up_rle_with_its_alpha_bits_and_the_tga2_footer() { + let mut data = vec![0u8; 2 * 2 * 4]; + // Top row red, bottom row blue, opaque. + for (i, px) in data.as_chunks_mut::<4>().0.iter_mut().enumerate() { + *px = if i < 2 { [255, 0, 0, 255] } else { [0, 0, 255, 255] }; + } + let file = encode(&Image::from_u8(2, 2, ChannelLayout::Rgba, data).unwrap(), Format::Tga, &EncodeOptions::default()).unwrap(); + assert_eq!(file[2], 10, "run-length true colour"); + assert_eq!((file[16], file[17]), (32, 8), "32 bits per pixel, 8 alpha bits, bottom-left origin"); + // The first stored row is the bottom one: a run of two blue pixels, in BGRA. + assert_eq!(&file[18..23], &[0x81, 255, 0, 0, 255]); + assert!(file.ends_with(b"TRUEVISION-XFILE.\0")); + let gray = encode(&Image::from_u8(2, 1, ChannelLayout::Gray, vec![1, 2]).unwrap(), Format::Tga, &EncodeOptions::default()).unwrap(); + assert_eq!((gray[2], gray[16], gray[17]), (11, 8, 0), "run-length grayscale, no alpha bits"); +} + +#[test] +fn every_layout_reads_back_exactly() { + for layout in [ChannelLayout::Gray, ChannelLayout::GrayA, ChannelLayout::Rgb, ChannelLayout::Rgba] { + for (w, h) in [(1, 1), (300, 4), (129, 3), (5, 130)] { + let img = sample(w, h, layout); + let file = encode(&img, Format::Tga, &EncodeOptions::default()).unwrap(); + let back = decode_as(Format::Tga, &file).unwrap(); + assert_eq!((back.dimensions(), back.layout()), ((w, h), layout), "{layout:?} {w}x{h}"); + assert_eq!(back.data(), img.data(), "{layout:?} {w}x{h}"); + } + } +} From 5bd428a23983ec9a3dedfffc38ede18a28424b86 Mon Sep 17 00:00:00 2001 From: Nicolas Falesy <112726756+nicolasfalesy@users.noreply.github.com> Date: Fri, 16 Oct 2026 22:16:45 -0400 Subject: [PATCH 63/85] =?UTF-8?q?perf(stack):=20Stack=20Mode=2011=C3=97=20?= =?UTF-8?q?faster=20(#6462)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Layer › Smart Objects › Stack Mode (`algo::stack::combine`) was one serial per-pixel loop (1.0x parallelism, identical at 1 and 16 threads), and Entropy built a HashMap per channel per pixel. - Output pixels are computed in parallel in 4096-pixel chunks (`par_rows`, so wasm keeps its serial path). Each chunk owns its scratch buffers. - Each pixel uses exactly the same arithmetic in the same order as before, so every mode except Entropy is bit-identical: 110 output hashes (10 modes x 11 cases: 3 x 24 MP, 7 layers of odd/mismatched sizes, transparency, HDR values, 1 layer, no layers) match the old code. - Entropy: a `[u32; 256]` histogram plus a short list of the bins touched replaces the HashMap; touched bins are summed in ascending order, so the result is now deterministic. The old HashMap summed in random iteration order and differed run to run (up to 1.2e-7 between two runs of the old code). New vs old output: max abs difference 1.19e-7 (one f32 ulp near 1.0), on up to ~10% of values in the 7-layer cases; the 3-layer 24 MP case is identical. - Modes that never use the mean (Median, Min, Max, Range, Summation, Entropy) no longer compute it; Median uses an unstable sort (same result under total_cmp). 3 x 24 MP, release, median of 5 interleaved before/after runs (load average ~3.5-4.5 from an unrelated build running alongside), ms: | Mode | before | after (16 thr) | speedup | after (1 thr) | |------------|-------:|---------------:|--------:|--------------:| | Median | 1100 | 99 | 11.1x | 1064 | | Mean | 308 | 36 | 8.6x | 338 | | StdDev | 342 | 41 | 8.3x | 380 | | Entropy | 4727 | 269 | 17.5x | 2730 | | Kurtosis | 458 | 55 | 8.3x | 488 | | Maximum | 368 | 36 | 10.1x | 346 | The other modes land between 8x and 10x. Max RSS for Median is unchanged (1.50 GB, of which 1.13 GB is the input layers). Tests: hard-coded bit patterns recorded from the old code for every mode but Entropy; Entropy against hand-computed values and run-to-run determinism; and a chunk-boundary test comparing a multi-chunk image to per-pixel results. Claude-Session: https://claude.ai/code/session_01AfRHB1wHUjrJx6agBYGnVu Co-authored-by: Claude Opus 5.5 --- crates/algo/src/stack.rs | 387 ++++++++++++++++++++++++++++++++++----- 1 file changed, 338 insertions(+), 49 deletions(-) diff --git a/crates/algo/src/stack.rs b/crates/algo/src/stack.rs index 2eec57e598..aa7ff9bfa1 100644 --- a/crates/algo/src/stack.rs +++ b/crates/algo/src/stack.rs @@ -23,24 +23,83 @@ pub enum Stat { Variance, } -fn stat(v: &mut [f32], s: Stat) -> f32 { +/// Reusable per-chunk scratch for [`Stat::Entropy`]: an 8-bit histogram plus the list of bins the +/// current pixel touched. Allocated once per parallel chunk instead of one `HashMap` per channel +/// per pixel (the old cost: ~4.6 s for 3 × 24 MP). +struct EntropyScratch { + counts: [u32; 256], + touched: Vec, +} + +impl EntropyScratch { + fn new() -> Self { + Self { counts: [0; 256], touched: Vec::new() } + } +} + +/// Shannon entropy (bits) of the 8-bit values in `v`, over log2(N). `v.len() >= 2`. +fn entropy(v: &[f32], scratch: &mut EntropyScratch) -> f32 { + let n = v.len() as f32; + for x in v { + let bin = (x.clamp(0.0, 1.0) * 255.0).round() as u8; + let c = &mut scratch.counts[usize::from(bin)]; + if *c == 0 { + scratch.touched.push(bin); + } + *c += 1; + } + // Sum the bins in ascending order. The old HashMap summed in its (randomly seeded) iteration + // order, and float addition is not associative, so the old result could differ in the last + // bit from run to run; a fixed order makes it deterministic. Sorting only the few touched + // bins keeps this O(samples), not O(256), per pixel. + scratch.touched.sort_unstable(); + let h: f32 = scratch + .touched + .iter() + .map(|&bin| { + let p = scratch.counts[usize::from(bin)] as f32 / n; + -p * p.log2() + }) + .sum(); + // Reset only the bins used, so the 256-entry array is never cleared wholesale. + for &bin in &scratch.touched { + scratch.counts[usize::from(bin)] = 0; + } + scratch.touched.clear(); + h / n.log2() +} + +fn stat(v: &mut [f32], s: Stat, scratch: &mut EntropyScratch) -> f32 { let n = v.len() as f32; if v.is_empty() { return 0.0; } + // Modes that need neither the mean nor the moments return before computing them (the old code + // computed the mean for every mode and threw it away; skipping it changes no result). + match s { + Stat::Summation => return v.iter().sum::(), + Stat::Minimum => return v.iter().copied().fold(f32::MAX, f32::min), + Stat::Maximum => return v.iter().copied().fold(f32::MIN, f32::max), + Stat::Range => return v.iter().copied().fold(f32::MIN, f32::max) - v.iter().copied().fold(f32::MAX, f32::min), + Stat::Median => { + // `total_cmp` is a total order whose ties are bit-identical values, so an unstable + // sort gives exactly the stable sort's result. + v.sort_unstable_by(f32::total_cmp); + let k = v.len(); + return if k % 2 == 1 { v[k / 2] } else { (v[k / 2 - 1] + v[k / 2]) / 2.0 }; + } + Stat::Entropy => { + if v.len() < 2 { + return 0.0; + } + return entropy(v, scratch); + } + _ => {} + } let mean = v.iter().sum::() / n; let m = |k: i32| v.iter().map(|x| (x - mean).powi(k)).sum::() / n; match s { Stat::Mean => mean, - Stat::Summation => v.iter().sum::(), - Stat::Minimum => v.iter().copied().fold(f32::MAX, f32::min), - Stat::Maximum => v.iter().copied().fold(f32::MIN, f32::max), - Stat::Range => v.iter().copied().fold(f32::MIN, f32::max) - v.iter().copied().fold(f32::MAX, f32::min), - Stat::Median => { - v.sort_by(f32::total_cmp); - let k = v.len(); - if k % 2 == 1 { v[k / 2] } else { (v[k / 2 - 1] + v[k / 2]) / 2.0 } - } Stat::Variance => m(2), Stat::StandardDeviation => m(2).sqrt(), Stat::Skewness => { @@ -53,55 +112,55 @@ fn stat(v: &mut [f32], s: Stat) -> f32 { // 0..10 → 0..1. if var < 1e-12 { 0.0 } else { (m(4) / (var * var) / 10.0).clamp(0.0, 1.0) } } - Stat::Entropy => { - if v.len() < 2 { - return 0.0; - } - let mut counts: std::collections::HashMap = std::collections::HashMap::new(); - for x in v.iter() { - *counts.entry((x.clamp(0.0, 1.0) * 255.0).round() as u8).or_default() += 1; - } - let h: f32 = counts - .values() - .map(|&c| { - let p = c as f32 / n; - -p * p.log2() - }) - .sum(); - h / n.log2() - } + // Handled above; a no-op keeps the match exhaustive without a panic path. + Stat::Summation | Stat::Minimum | Stat::Maximum | Stat::Range | Stat::Median | Stat::Entropy => 0.0, } } +/// Output pixels per parallel task: big enough to amortise the per-task scratch buffers and +/// rayon's scheduling, small enough (6000 tasks for 24 MP) to balance across many cores. +const CHUNK: usize = 4096; + /// Combines `layers` (each `len` pixels) with statistic `s`. Colour results retain their float /// range; quantisation/clipping belongs to the destination surface, not the statistic. pub fn combine(layers: &[Vec<[f32; 4]>], s: Stat) -> Vec<[f32; 4]> { let len = layers.iter().map(Vec::len).max().unwrap_or(0); let mut out = vec![[0.0f32; 4]; len]; - let mut samples: [Vec; 3] = Default::default(); - for (i, o) in out.iter_mut().enumerate() { - for v in samples.iter_mut() { - v.clear(); - } - let mut alpha = 0.0f32; - for l in layers { - if let Some(p) = l.get(i) - && p[3] > 0.0 - { - for c in 0..3 { - samples[c].push(p[c]); - } - alpha = alpha.max(p[3]); + // Every output pixel depends only on the input pixels at the same index, so chunks of the + // output are independent: `par_rows` over a `CHUNK`-wide "row" with one channel (it runs + // serially on wasm). The per-pixel arithmetic is unchanged, hence the result is bit-identical + // to the serial loop for every mode (Entropy: see [`entropy`]). + crate::photo_util::par_rows(&mut out, CHUNK, 1, |chunk, outs| { + // Scratch is per chunk, not per pixel: the old loop's three `Vec`s were already reused + // across pixels, so keep that, and give each task its own set. + let mut samples: [Vec; 3] = [0, 1, 2].map(|_| Vec::with_capacity(layers.len())); + let mut scratch = EntropyScratch::new(); + let base = chunk * CHUNK; + for (j, o) in outs.iter_mut().enumerate() { + let i = base + j; + for v in samples.iter_mut() { + v.clear(); } + let mut alpha = 0.0f32; + for l in layers { + if let Some(p) = l.get(i) + && p[3] > 0.0 + { + for c in 0..3 { + samples[c].push(p[c]); + } + alpha = alpha.max(p[3]); + } + } + if alpha <= 0.0 { + continue; + } + for c in 0..3 { + o[c] = stat(&mut samples[c], s, &mut scratch); + } + o[3] = alpha; } - if alpha <= 0.0 { - continue; - } - for c in 0..3 { - o[c] = stat(&mut samples[c], s); - } - o[3] = alpha; - } + }); out } @@ -168,4 +227,234 @@ mod tests { let layers = vec![px(0.5), px(0.5), px(1.0)]; assert_eq!(combine(&layers, Stat::Median)[0][0], 0.5); } + + /// Five layers over six pixels (the last layer is short). Covers opaque, partly transparent, + /// fully transparent, HDR / negative, single-sample and tied-value pixels. + fn fixture() -> Vec> { + vec![ + vec![[0.10, 0.20, 0.30, 1.0], [0.5, 0.5, 0.5, 1.0], [0.9, 0.9, 0.9, 0.0], [-2.0, 4.0, 0.0, 1.0], [0.7, 0.2, 0.1, 0.5], [0.25, 0.25, 0.25, 1.0]], + vec![[0.90, 0.60, 0.30, 1.0], [0.0, 0.0, 0.0, 0.0], [0.8, 0.8, 0.8, 0.0], [3.0, -1.0, 0.5, 0.75], [0.0, 0.0, 0.0, 0.0], [0.25, 0.75, 0.25, 1.0]], + vec![[0.20, 0.90, 0.30, 0.5], [0.1, 0.2, 0.3, 1.0], [0.7, 0.7, 0.7, 0.0], [0.5, 0.5, 0.5, 1.0], [0.0, 0.0, 0.0, 0.0], [0.5, 0.75, 0.25, 0.25]], + vec![[0.30, 0.05, 0.30, 0.25], [0.3, 0.4, 0.5, 0.5], [0.6, 0.6, 0.6, 0.0], [1.5, 1.5, 1.5, 1.0], [0.0, 0.0, 0.0, 0.0], [0.25, 0.75, 0.5, 1.0]], + vec![[0.10, 0.70, 0.99, 1.0], [0.9, 0.1, 0.2, 1.0], [0.5, 0.5, 0.5, 0.0], [0.0, 0.0, 0.0, 1.0]], + ] + } + + fn bits(px: &[[f32; 4]]) -> Vec<[u32; 4]> { + px.iter().map(|p| p.map(f32::to_bits)).collect() + } + + /// Every mode except Entropy is bit-for-bit what the old serial loop produced. The expected + /// bit patterns were recorded by running the unmodified `combine` on [`fixture`]. + #[test] + fn statistics_are_bit_identical_to_the_serial_implementation() { + let layers = fixture(); + let expected: Vec<(Stat, Vec<[u32; 4]>)> = vec![ + ( + Stat::Kurtosis, + [ + [0x3e96956b, 0x3e182e3f, 0x3ea66664, 0x3f800000], + [0x3e3d004c, 0x3e0b4396, 0x3e030e1f, 0x3f800000], + [0x00000000, 0x00000000, 0x00000000, 0x00000000], + [0x3e594eb2, 0x3e6ef420, 0x3e7ffffe, 0x3f800000], + [0x00000000, 0x00000000, 0x00000000, 0x3f000000], + [0x3e6eeeee, 0x3e6eeeee, 0x3e6eeeee, 0x3f800000], + ] + .to_vec(), + ), + ( + Stat::Maximum, + [ + [0x3f666666, 0x3f666666, 0x3f7d70a4, 0x3f800000], + [0x3f666666, 0x3f000000, 0x3f000000, 0x3f800000], + [0x00000000, 0x00000000, 0x00000000, 0x00000000], + [0x40400000, 0x40800000, 0x3fc00000, 0x3f800000], + [0x3f333333, 0x3e4ccccd, 0x3dcccccd, 0x3f000000], + [0x3f000000, 0x3f400000, 0x3f000000, 0x3f800000], + ] + .to_vec(), + ), + ( + Stat::Mean, + [ + [0x3ea3d70a, 0x3efae148, 0x3ee0418a, 0x3f800000], + [0x3ee66666, 0x3e99999a, 0x3ec00000, 0x3f800000], + [0x00000000, 0x00000000, 0x00000000, 0x00000000], + [0x3f19999a, 0x3f800000, 0x3f000000, 0x3f800000], + [0x3f333333, 0x3e4ccccd, 0x3dcccccd, 0x3f000000], + [0x3ea00000, 0x3f200000, 0x3ea00000, 0x3f800000], + ] + .to_vec(), + ), + ( + Stat::Median, + [ + [0x3e4ccccd, 0x3f19999a, 0x3e99999a, 0x3f800000], + [0x3ecccccd, 0x3e99999a, 0x3ecccccd, 0x3f800000], + [0x00000000, 0x00000000, 0x00000000, 0x00000000], + [0x3f000000, 0x3f000000, 0x3f000000, 0x3f800000], + [0x3f333333, 0x3e4ccccd, 0x3dcccccd, 0x3f000000], + [0x3e800000, 0x3f400000, 0x3e800000, 0x3f800000], + ] + .to_vec(), + ), + ( + Stat::Minimum, + [ + [0x3dcccccd, 0x3d4ccccd, 0x3e99999a, 0x3f800000], + [0x3dcccccd, 0x3dcccccd, 0x3e4ccccd, 0x3f800000], + [0x00000000, 0x00000000, 0x00000000, 0x00000000], + [0xc0000000, 0xbf800000, 0x00000000, 0x3f800000], + [0x3f333333, 0x3e4ccccd, 0x3dcccccd, 0x3f000000], + [0x3e800000, 0x3e800000, 0x3e800000, 0x3f800000], + ] + .to_vec(), + ), + ( + Stat::Range, + [ + [0x3f4ccccc, 0x3f599999, 0x3f30a3d7, 0x3f800000], + [0x3f4ccccc, 0x3ecccccd, 0x3e99999a, 0x3f800000], + [0x00000000, 0x00000000, 0x00000000, 0x00000000], + [0x40a00000, 0x40a00000, 0x3fc00000, 0x3f800000], + [0x00000000, 0x00000000, 0x00000000, 0x3f000000], + [0x3e800000, 0x3f000000, 0x3e800000, 0x3f800000], + ] + .to_vec(), + ), + ( + Stat::Skewness, + [ + [0x3f522026, 0x3ee7cf76, 0x3f600000, 0x3f800000], + [0x3f1bd152, 0x3efffffe, 0x3ee4a11b, 0x3f800000], + [0x00000000, 0x00000000, 0x00000000, 0x00000000], + [0x3eedb65a, 0x3f2ea754, 0x3f3a6c7a, 0x3f800000], + [0x3f000000, 0x3f000000, 0x3f000000, 0x3f000000], + [0x3f49e69e, 0x3e58658a, 0x3f49e69e, 0x3f800000], + ] + .to_vec(), + ), + ( + Stat::StandardDeviation, + [ + [0x3e99421e, 0x3ea23b6b, 0x3e8d4fdf, 0x3f800000], + [0x3e97739e, 0x3e21e89b, 0x3e050581, 0x3f800000], + [0x00000000, 0x00000000, 0x00000000, 0x00000000], + [0x3fd3e0b1, 0x3fd9f9e5, 0x3f0c378c, 0x3f800000], + [0x00000000, 0x00000000, 0x00000000, 0x3f000000], + [0x3dddb3d7, 0x3e5db3d7, 0x3dddb3d7, 0x3f800000], + ] + .to_vec(), + ), + ( + Stat::Summation, + [ + [0x3fcccccd, 0x401ccccd, 0x400c28f6, 0x3f800000], + [0x3fe66666, 0x3f99999a, 0x3fc00000, 0x3f800000], + [0x00000000, 0x00000000, 0x00000000, 0x00000000], + [0x40400000, 0x40a00000, 0x40200000, 0x3f800000], + [0x3f333333, 0x3e4ccccd, 0x3dcccccd, 0x3f000000], + [0x3fa00000, 0x40200000, 0x3fa00000, 0x3f800000], + ] + .to_vec(), + ), + ( + Stat::Variance, + [ + [0x3db78033, 0x3dcd9e83, 0x3d9c022a, 0x3f800000], + [0x3db33332, 0x3ccccccd, 0x3c8a3d70, 0x3f800000], + [0x00000000, 0x00000000, 0x00000000, 0x00000000], + [0x402f5c29, 0x4039999a, 0x3e99999a, 0x3f800000], + [0x00000000, 0x00000000, 0x00000000, 0x3f000000], + [0x3c400000, 0x3d400000, 0x3c400000, 0x3f800000], + ] + .to_vec(), + ), + ]; + for (mode, want) in expected { + assert_eq!(bits(&combine(&layers, mode)), want, "{mode:?}"); + } + } + + /// Entropy against values worked out by hand (bits of the 8-bit histogram, over log2(N)). + #[test] + fn entropy_matches_hand_computed_values() { + let out = combine(&fixture(), Stat::Entropy); + let h = |ps: &[f64]| -> f64 { + let n: f64 = ps.iter().sum(); + ps.iter().map(|c| -(c / n) * (c / n).log2()).sum::() / n.log2() + }; + // Pixel 1: four distinct values -> 2 bits / log2(4) = exactly 1. + assert_eq!(out[1][0], 1.0); + // Pixel 2: every sample transparent -> untouched. + assert_eq!(out[2], [0.0; 4]); + // Pixel 3, red: -2 and 0 land in bin 0, 3 and 1.5 clamp into bin 255, 0.5 is alone. + assert!((f64::from(out[3][0]) - h(&[2.0, 2.0, 1.0])).abs() < 1e-6, "{}", out[3][0]); + // Pixel 4: a single contributing sample has no entropy. + assert_eq!(out[4], [0.0, 0.0, 0.0, 0.5]); + // Pixel 5: three samples share a bin, one is alone, in every channel. + for (c, v) in out[5].iter().take(3).enumerate() { + assert!((f64::from(*v) - h(&[3.0, 1.0])).abs() < 1e-6, "channel {c}: {v}"); + } + } + + /// The old HashMap summed in a random order, so Entropy could differ by an ulp between runs. + /// Now the bins are summed in ascending order: repeated runs (and any thread count) agree. + #[test] + fn entropy_is_deterministic() { + let layers: Vec> = (0..7) + .map(|k| { + (0..3 * CHUNK + 17).map(|i| [((i * 7 + k * 13) % 11) as f32 / 10.0, ((i + k) % 5) as f32 / 4.0, ((i * k) % 9) as f32 / 8.0, 1.0]).collect() + }) + .collect(); + let first = bits(&combine(&layers, Stat::Entropy)); + for _ in 0..8 { + assert_eq!(bits(&combine(&layers, Stat::Entropy)), first); + } + } + + /// Chunking is invisible: a multi-chunk image (with a ragged last chunk and layers of unequal + /// length) gives, pixel by pixel, what combining that pixel on its own gives. + #[test] + fn chunked_result_equals_per_pixel_result() { + let len = 2 * CHUNK + 5; + let layer = |k: usize, n: usize| -> Vec<[f32; 4]> { + (0..n) + .map(|i| { + [ + ((i * 31 + k * 7) % 101) as f32 / 100.0, + ((i * 17 + k) % 53) as f32 / 52.0 - 0.2, + ((i + 3 * k) % 29) as f32 / 28.0, + if (i + k).is_multiple_of(4) { 0.0 } else { 0.5 + ((i + k) % 3) as f32 / 4.0 }, + ] + }) + .collect() + }; + let layers = vec![layer(1, len), layer(2, CHUNK + 3), layer(3, len - 1), layer(4, 0), layer(5, len)]; + for mode in [ + Stat::Entropy, + Stat::Kurtosis, + Stat::Maximum, + Stat::Mean, + Stat::Median, + Stat::Minimum, + Stat::Range, + Stat::Skewness, + Stat::StandardDeviation, + Stat::Summation, + Stat::Variance, + ] { + let whole = combine(&layers, mode); + assert_eq!(whole.len(), len); + for i in (0..len).step_by(97).chain([CHUNK - 1, CHUNK, CHUNK + 1, 2 * CHUNK - 1, 2 * CHUNK, len - 1]) { + let one: Vec> = layers.iter().map(|l| l.get(i).map(|p| vec![*p]).unwrap_or_default()).collect(); + assert_eq!(whole[i].map(f32::to_bits), combine(&one, mode)[0].map(f32::to_bits), "{mode:?} pixel {i}"); + } + } + } + + #[test] + fn no_layers_gives_an_empty_result() { + assert!(combine(&[], Stat::Median).is_empty()); + } } From 3d2914368d38aa8e5c336415241ceadc88f5ab00 Mon Sep 17 00:00:00 2001 From: David Mann Date: Sat, 17 Oct 2026 04:17:27 +0200 Subject: [PATCH 64/85] PSD type: clamp warp bend and distortion before the f32 cast (#6856) (#6857) A TySh warpValue, warpPerspective or warpPerspectiveOther beyond f32 became inf and the warp was silently dropped; they are now clamped to -100..100 like type.warpText. Co-authored-by: Claude Opus 5.5 (1M context) --- crates/text/src/psd.rs | 22 +++++++++++++++++++--- 1 file changed, 19 insertions(+), 3 deletions(-) diff --git a/crates/text/src/psd.rs b/crates/text/src/psd.rs index 0092fcf92e..8c502c75af 100644 --- a/crates/text/src/psd.rs +++ b/crates/text/src/psd.rs @@ -351,11 +351,14 @@ pub fn text_layer_from_tysh(data: &[u8], dpi: f32) -> Option { if let Some(w) = &t.warp { let style = enum_value(w, "warpStyle").unwrap_or_else(|| "warpNone".into()); if style != "warpNone" { + // Bend and distortion are percentages in -100..100 (as `type.warpText` clamps them); + // clamping before the cast keeps a value beyond f32 from becoming inf. + let pct = |k: &str| dnum(w.get(k)).unwrap_or(0.0).clamp(-100.0, 100.0) as f32; layer.warp = Some(TextWarp { style, - value: dnum(w.get("warpValue")).unwrap_or(0.0) as f32, - horizontal_distortion: dnum(w.get("warpPerspective")).unwrap_or(0.0) as f32, - vertical_distortion: dnum(w.get("warpPerspectiveOther")).unwrap_or(0.0) as f32, + value: pct("warpValue"), + horizontal_distortion: pct("warpPerspective"), + vertical_distortion: pct("warpPerspectiveOther"), horizontal: enum_value(w, "warpRotate").as_deref() != Some("Vrtc"), }); } @@ -1362,6 +1365,19 @@ mod nonfinite_tests { assert_eq!(char_style(None, &d, &[], 1.0).size_pt, 24.0); } + #[test] + fn warp_values_beyond_f32_clamp_to_the_warp_range() { + let warp = Descriptor::new("warp") + .with("warpStyle", enumv("warpStyle", "warpArc")) + .with("warpValue", D::Double(1e39)) + .with("warpPerspective", D::Double(-1e39)) + .with("warpPerspectiveOther", D::Double(250.0)) + .with("warpRotate", enumv("Ornt", "Hrzn")); + let tysh = write_tysh(&TySh { transform: Affine::IDENTITY, text: Descriptor::new("TxLr"), warp: Some(warp), bounds: [0.0; 4] }); + let w = text_layer_from_tysh(&tysh, 72.0).and_then(|l| l.warp).unwrap(); + assert_eq!((w.value, w.horizontal_distortion, w.vertical_distortion), (100.0, -100.0, 100.0)); + } + #[test] fn infinite_dpi_does_not_write_a_zero_size() { let layer = From 20013ef9bd8040f09b6a646241333502cd4a22f1 Mon Sep 17 00:00:00 2001 From: MarekSpodBiedry <73122968+MarekSpodBiedry@users.noreply.github.com> Date: Sat, 17 Oct 2026 04:18:17 +0200 Subject: [PATCH 65/85] Linked smart objects: don't refresh a document while a background job runs on it (#4973) (#5074) --- crates/engine/src/link_watch.rs | 60 ++++++++++++++++++++++++++++++++- 1 file changed, 59 insertions(+), 1 deletion(-) diff --git a/crates/engine/src/link_watch.rs b/crates/engine/src/link_watch.rs index 5c235fb919..dbbfec2be7 100644 --- a/crates/engine/src/link_watch.rs +++ b/crates/engine/src/link_watch.rs @@ -16,6 +16,9 @@ //! keeps waiting for Update Modified Content, as in Photoshop); one that can't be read (another app //! is still writing it) is tried again once it changes again, not on every look. Only smart objects //! of the document itself follow their files, not ones nested in another smart object's contents. +//! While a background job runs on the document, its files aren't looked at: an update would replace +//! the snapshot the job works on and its result would be thrown away. The first look after the job +//! ends catches up. use std::collections::{BTreeMap, HashMap, HashSet}; use std::sync::{Arc, Mutex, PoisonError}; @@ -103,6 +106,12 @@ impl Session { if self.active().map(|d| d.doc.id.0) != Some(doc) { return Ok(json!({"updated": []})); } + // A background job holds the document: refreshing now would replace the snapshot it works + // on and its result would be discarded. The stamps stay as they were, so the change is + // seen again by the first look after the job ends. + if self.active_job().is_some() { + return Ok(json!({"updated": []})); + } // This document's last stamps; those of closed documents are dropped. let open: HashSet = self.documents().iter().map(|d| d.doc.id.0).collect(); let mut before: HashMap = HashMap::new(); @@ -155,7 +164,7 @@ mod tests { use photocraft_geom::Rect; use serde_json::json; - use crate::Session; + use crate::{EngineError, Session}; fn dir(name: &str) -> std::path::PathBuf { let d = std::env::temp_dir().join(format!("photocraft-link-watch-{name}-{}", std::process::id())); @@ -280,4 +289,53 @@ mod tests { assert!(is(centre(&s), BLUE)); assert!(s.link_scan.is_none()); } + + #[test] + fn a_refresh_waits_for_a_running_job_on_the_document() { + let d = dir("job"); + let path = d.join("art.png"); + std::fs::write(&path, png(10, 10, "#ff0000")).unwrap(); + let (mut s, id) = placed(&path); + s.execute("layer.smartObjects.updateChanged", json!({})).unwrap(); + let (release, job) = gated_job(&mut s); + save(&path, &png(10, 10, "#0000ff")); + let snapshot = s.active().unwrap().doc.clone(); + // The path the desktop app's timer takes: it doesn't go through the command conflict check. + assert_eq!(super::update_changed(&mut s, &json!({})).unwrap(), json!({"updated": []})); + assert!(std::sync::Arc::ptr_eq(&s.active().unwrap().doc, &snapshot), "the job's snapshot must stay in place"); + release.send(()).unwrap(); + s.wait_job(job).unwrap(); + // The job finished; the change that came meanwhile is picked up exactly once. + assert_eq!(s.execute("layer.smartObjects.updateChanged", json!({})).unwrap(), json!({"updated": [id]})); + assert!(is(centre(&s), BLUE)); + assert_eq!(s.execute("layer.smartObjects.updateChanged", json!({})).unwrap(), json!({"updated": []})); + } + + #[test] + fn a_job_on_another_document_does_not_hold_up_a_refresh() { + let d = dir("job-other"); + let path = d.join("art.png"); + std::fs::write(&path, png(10, 10, "#ff0000")).unwrap(); + let (mut s, id) = placed(&path); + s.execute("layer.smartObjects.updateChanged", json!({})).unwrap(); + s.execute("file.new", json!({"width": 10, "height": 10})).unwrap(); + let (release, job) = gated_job(&mut s); + s.set_active(0); + save(&path, &png(10, 10, "#0000ff")); + assert_eq!(super::update_changed(&mut s, &json!({})).unwrap(), json!({"updated": [id]})); + assert!(is(centre(&s), BLUE)); + release.send(()).unwrap(); + s.wait_job(job).unwrap(); + } + + /// A background job that locks the active document until something is sent on the returned + /// sender. + fn gated_job(s: &mut Session) -> (std::sync::mpsc::Sender<()>, crate::jobs::JobId) { + let (release, gate) = std::sync::mpsc::channel::<()>(); + let r = s.start_job("test.gated", json!({}), "Gated job", true, move |_| gate.recv().map_err(|_| EngineError::Cancelled), |_, ()| Ok(json!(null))); + match r.unwrap() { + crate::jobs::Started::Job(j) => (release, j), + other => panic!("expected a background job, got {other:?}"), + } + } } From fbfc72ceb59f14ef0610e8335d230fc4cc26f392 Mon Sep 17 00:00:00 2001 From: MarekSpodBiedry <73122968+MarekSpodBiedry@users.noreply.github.com> Date: Sat, 17 Oct 2026 04:18:47 +0200 Subject: [PATCH 66/85] Close All, Close Others and Quit ask again about documents dirtied during the prompt (#4954) (#5079) --- crates/ui-egui/src/discard_ui.rs | 51 ++++++++++++++++++++++++++++++++ 1 file changed, 51 insertions(+) diff --git a/crates/ui-egui/src/discard_ui.rs b/crates/ui-egui/src/discard_ui.rs index 6720015458..3dbf33c905 100644 --- a/crates/ui-egui/src/discard_ui.rs +++ b/crates/ui-egui/src/discard_ui.rs @@ -157,6 +157,25 @@ fn advance(app: &mut PhotocraftApp, ctx: &egui::Context) { if next.is_some() { return; } + // A save or close hook may have dirtied a document that was clean when the prompt opened (or + // made a new one): ask about those before the final close, which discards without checking. + if one_by_one && let Some(p) = app.discard.as_ref() { + let (id, target) = (p.id.clone(), p.target); + let rest: Vec = app + .session + .documents() + .iter() + .filter(|d| d.is_dirty() && (matches!(reach(&id), Some(Reach::All)) || Some(d.doc.id) != target)) + .map(|d| d.doc.id) + .collect(); + if !rest.is_empty() { + show_tab(app, rest.first().copied()); + if let Some(p) = app.discard.as_mut() { + p.docs = rest; + } + return; + } + } let Some(Prompt { id, mut params, target, .. }) = app.discard.take() else { return }; if id == EXIT { app.allow_close = true; @@ -495,6 +514,38 @@ mod tests { assert!(app.session.documents().is_empty()); } + /// A document that became dirty while the prompt was up is asked about before the final close (#4954). + #[test] + fn a_document_dirtied_during_the_prompt_is_asked_about_before_the_final_close() { + for command in ["file.closeAll", EXIT] { + let mut app = app_with_docs(2); + let ctx = egui::Context::default(); + make_dirty(&mut app, 0); + let late = doc_id(&app, 1); + crate::menus::invoke(&mut app, &ctx, command, json!({})).unwrap(); + make_dirty(&mut app, 1); + advance(&mut app, &ctx); + assert_eq!(app.discard.as_ref().map(|p| p.docs.clone()), Some(vec![late]), "{command}: asks about the newly dirty document"); + assert!(!app.allow_close, "{command}"); + assert_eq!(app.session.documents().len(), 1, "{command}: it is not discarded unasked"); + advance(&mut app, &ctx); + assert!(app.discard.is_none(), "{command}"); + } + // Close Others asks about the others too, but still not about the document it keeps. + let mut app = app_with_docs(3); + let ctx = egui::Context::default(); + make_dirty(&mut app, 0); + let (late, kept) = (doc_id(&app, 1), doc_id(&app, 2)); + crate::menus::invoke(&mut app, &ctx, "file.closeOthers", json!({"document": 2})).unwrap(); + make_dirty(&mut app, 1); + make_dirty(&mut app, 2); + advance(&mut app, &ctx); + assert_eq!(app.discard.as_ref().map(|p| p.docs.clone()), Some(vec![late]), "file.closeOthers: asks about the newly dirty other document"); + advance(&mut app, &ctx); + assert!(app.discard.is_none()); + assert_eq!(app.session.documents().iter().map(|d| d.doc.id).collect::>(), [kept]); + } + #[test] fn close_others_closes_each_answered_document_and_keeps_its_target() { let mut app = app_with_docs(3); From 4c881326ea6f0f4ab77722cd1048ff69c38b51ff Mon Sep 17 00:00:00 2001 From: Korg1ch <57859048+Korg1ch@users.noreply.github.com> Date: Sat, 17 Oct 2026 05:19:06 +0300 Subject: [PATCH 67/85] fix(windows): keep crash-recovery autosaves out of the roaming profile (#5648) (#6412) * fix(windows): keep crash-recovery autosaves out of the roaming profile (#5648) * test(windows): cover the local recovery folder and moving old autosaves (#5648) --- apps/photocraft/src/app_dirs.rs | 670 ++++++++++++++++++++++++++++++++ apps/photocraft/src/services.rs | 8 +- docs/control-protocol.md | 4 +- packaging/windows/portable.txt | 8 +- 4 files changed, 682 insertions(+), 8 deletions(-) diff --git a/apps/photocraft/src/app_dirs.rs b/apps/photocraft/src/app_dirs.rs index 2897bebdb0..5775e254df 100644 --- a/apps/photocraft/src/app_dirs.rs +++ b/apps/photocraft/src/app_dirs.rs @@ -9,6 +9,9 @@ //! folder can't be written (read-only medium, Program Files), the app warns and falls back. //! 3. The platform convention: macOS `~/Library/Application Support/Photocraft`, Windows //! `%APPDATA%\Photocraft`, Linux `$XDG_CONFIG_HOME/photocraft` or `~/.config/photocraft`. +//! +//! One exception: on Windows the platform convention keeps crash-recovery autosaves out of the +//! roaming profile ([`recovery_dir`]). use std::ffi::OsString; use std::path::{Path, PathBuf}; @@ -60,6 +63,379 @@ pub fn config_dir() -> Option { current().dir.clone() } +/// The crash-recovery folder's name. +const RECOVERY: &str = "Recovery"; + +/// Where crash-recovery autosaves live, with any left in the settings directory by an earlier +/// version moved there first ([`adopt_legacy_recovery`]). Call it once, at startup. +pub fn recovery_dir() -> Option { + let data = current(); + let dir = resolve_recovery(data, |k| std::env::var_os(k))?; + Some(match data.dir.as_ref().map(|d| d.join(RECOVERY)) { + Some(legacy) => adopt_legacy_recovery(&legacy, &dir), + None => dir, + }) +} + +/// The recovery folder for `data` (#5648). Autosaves hold whole documents and are rewritten every +/// few minutes, so on Windows the platform convention puts them in the machine-local +/// `%LOCALAPPDATA%\Photocraft\Recovery`: the roaming `%APPDATA%` may be synced at sign-in and +/// sign-out or capped by policy. They stay out of `%TEMP%`, which cleanup tools may empty before +/// a crashed document is recovered. The override and portable folders keep everything in one +/// place, and other platforms already keep these folders local. +pub fn resolve_recovery(data: &DataDir, env: impl Fn(&str) -> Option) -> Option { + if cfg!(windows) + && data.mode == Mode::Platform + && let Some(local) = env("LOCALAPPDATA").filter(|l| !l.is_empty()) + { + return Some(PathBuf::from(local).join("Photocraft").join(RECOVERY)); + } + data.dir.as_ref().map(|d| d.join(RECOVERY)) +} + +/// The lock file, beside the recovery folder, that keeps two PhotoCraft processes starting at +/// once (`--new-instance`, control launches) from moving the same autosaves. +const MIGRATION_LOCK: &str = "recovery-migration.lock"; + +/// How long a launch waits for another one's move before going ahead without moving anything. +const MIGRATION_WAIT: std::time::Duration = std::time::Duration::from_secs(5); + +/// Suffix of a copy staged for publishing ([`stage_copy`]): disposable, the original still exists. +const STAGED: &str = ".moving"; + +/// Suffix of an original set aside while it is copied to another volume ([`move_across`]): it +/// may be the only copy, so it is put back, never thrown away, if the move doesn't finish. +const HELD: &str = ".held"; + +/// Move autosaves from `legacy` (the recovery folder of versions before #5648) into `dir`, so a +/// crash just before an update is still offered for recovery. Returns the folder this process +/// uses for its recovery store. That is `legacy` only when `dir` doesn't exist and no process can +/// be moving autosaves (the move failed, or its lock can't be made), so nothing deletes what is +/// written there. A launch that finds another one moving uses `dir`, which the move never +/// replaces or removes. See [`adopt_locked`] for how they move. +pub fn adopt_legacy_recovery(legacy: &Path, dir: &Path) -> PathBuf { + adopt_legacy_recovery_waiting(legacy, dir, MIGRATION_WAIT) +} + +/// [`adopt_legacy_recovery`], waiting up to `wait` for another process's move. +fn adopt_legacy_recovery_waiting(legacy: &Path, dir: &Path, wait: std::time::Duration) -> PathBuf { + let held_aside = || legacy.parent().is_some_and(|p| !side_entries(p, legacy, HELD).is_empty()); + if legacy == dir || (!legacy.is_dir() && !held_aside()) { + return dir.to_path_buf(); + } + // An empty old folder has nothing to move (`remove_dir` only removes an empty one). + if !held_aside() && std::fs::remove_dir(legacy).is_ok() { + return dir.to_path_buf(); + } + let unmoved = || if dir.exists() || !legacy.is_dir() { dir.to_path_buf() } else { legacy.to_path_buf() }; + let Some(parent) = dir.parent() else { return unmoved() }; + let lock = + std::fs::create_dir_all(parent).and_then(|()| std::fs::OpenOptions::new().create(true).truncate(false).write(true).open(parent.join(MIGRATION_LOCK))); + let Ok(lock) = lock else { + // No lock file means no other process can be moving: the old folder is safe to use. + log::warn!("couldn't open the recovery move lock in {}; autosaves stay where they are", parent.display()); + return unmoved(); + }; + let deadline = std::time::Instant::now() + wait; + loop { + match lock.try_lock() { + Ok(()) => break, + Err(std::fs::TryLockError::WouldBlock) if std::time::Instant::now() < deadline => std::thread::sleep(std::time::Duration::from_millis(50)), + Err(std::fs::TryLockError::WouldBlock) => { + // Another process is moving and may yet remove the old folder: never write there. + log::info!("another PhotoCraft is moving crash-recovery autosaves; using {}", dir.display()); + return dir.to_path_buf(); + } + Err(std::fs::TryLockError::Error(e)) => { + // Locking fails for every process here alike, so none can be moving. + log::warn!("couldn't lock {}: {e}; autosaves stay where they are", parent.join(MIGRATION_LOCK).display()); + return unmoved(); + } + } + } + // The lock file stays (removing it would race a process about to lock it); the OS releases + // the lock when `lock` is dropped or the process ends. + let used = adopt_locked(legacy, dir); + if used == legacy { + // This process writes its autosaves to the old folder: it keeps the lock for as long as it + // runs, so no other launch moves (and removes) the folder under it. + KEPT_LOCKS.lock().unwrap_or_else(std::sync::PoisonError::into_inner).push(lock); + } + used +} + +/// Migration locks of the old folders this process uses (see [`adopt_legacy_recovery`]), kept +/// until it ends. +static KEPT_LOCKS: std::sync::Mutex> = std::sync::Mutex::new(Vec::new()); + +/// The move itself, under the migration lock. First, what a process that ended mid-move left is +/// tidied: originals it set aside are put back, copies it staged are removed. An empty `legacy` +/// is then just removed. Otherwise the whole folder moves if `dir` doesn't exist yet; when both +/// exist, each autosave (`.pcraft` bundle and `.json` sidecar, see +/// `photocraft_format::autosave`) moves as a whole, bundle first, unless `dir` already has either +/// part, and one that stays is left in `legacy` untouched. Every move is a [`move_path`]. +fn adopt_locked(legacy: &Path, dir: &Path) -> PathBuf { + if let Some(parent) = legacy.parent() { + for held in side_entries(parent, legacy, HELD) { + put_back(&held, legacy); + } + } + // Autosave parts held aside inside the old folder by a per-autosave move. + let held_parts: Vec<(PathBuf, String)> = std::fs::read_dir(legacy) + .into_iter() + .flatten() + .flatten() + .filter_map(|e| e.file_name().to_str().and_then(|n| side_of(n, HELD)).map(|orig| (e.path(), orig.to_owned()))) + .collect(); + for (held, original) in held_parts { + put_back(&held, &legacy.join(original)); + } + for folder in [dir.parent(), Some(dir)].into_iter().flatten() { + remove_stale_staging(folder); + } + // Another process may have finished the move while this one waited. + if !legacy.is_dir() { + return dir.to_path_buf(); + } + if std::fs::remove_dir(legacy).is_ok() { + return dir.to_path_buf(); + } + if !dir.exists() { + // The common case: the whole folder moves at once. + return match move_path(legacy, dir) { + Ok(()) => { + log::info!("moved crash-recovery autosaves from {} to {}", legacy.display(), dir.display()); + dir.to_path_buf() + } + Err(e) => { + log::warn!("couldn't move crash-recovery autosaves from {} to {} ({e}); using the old folder", legacy.display(), dir.display()); + if legacy.is_dir() { legacy.to_path_buf() } else { dir.to_path_buf() } + } + }; + } + move_entries(legacy, dir); + // Only an emptied folder goes; anything that couldn't move stays for a later launch. + let _ = std::fs::remove_dir(legacy); + dir.to_path_buf() +} + +/// Move each autosave of `from` into `to` (both folders): bundle first, then sidecar, skipped if +/// `to` has either part. Temp files of an interrupted write (`photocraft_format::atomic`) go +/// along too, for the recovery store's startup sweep there. +fn move_entries(from: &Path, to: &Path) { + let Ok(entries) = std::fs::read_dir(from) else { return }; + let names: Vec = entries.flatten().filter_map(|e| e.file_name().to_str().map(str::to_owned)).collect(); + let keys: std::collections::BTreeSet<&str> = names.iter().filter_map(|n| n.strip_suffix(".pcraft").or_else(|| n.strip_suffix(".json"))).collect(); + for key in keys { + let parts = [format!("{key}.pcraft"), format!("{key}.json")]; + if parts.iter().any(|p| std::fs::symlink_metadata(to.join(p)).is_ok()) { + continue; + } + // A bundle that moves without its sidecar is still listed (`list_recovery` reads a + // committed bundle's manifest); a sidecar must never move without its bundle. + for part in &parts { + let src = from.join(part); + if std::fs::symlink_metadata(&src).is_ok() + && let Err(e) = move_path(&src, &to.join(part)) + { + log::warn!("couldn't move {} to {}: {e}", src.display(), to.display()); + break; + } + } + } + for name in names.iter().filter(|n| n.ends_with(".pcsave.tmp")) { + let _ = move_path(&from.join(name), &to.join(name)); + } +} + +/// How deep [`copy_tree`] follows folders: an autosave bundle is a few levels deep. +const MAX_COPY_DEPTH: u32 = 16; + +fn already_exists(path: &Path) -> std::io::Error { + std::io::Error::new(std::io::ErrorKind::AlreadyExists, format!("{} already exists", path.display())) +} + +/// A path beside `of`, named `..-`, that no other call uses. +fn side_path(of: &Path, suffix: &str) -> std::io::Result { + static NEXT: std::sync::atomic::AtomicU32 = std::sync::atomic::AtomicU32::new(0); + let parent = of.parent().ok_or_else(|| std::io::Error::other(format!("{} has no parent folder", of.display())))?; + let name = of.file_name().map(|n| n.to_string_lossy().into_owned()).unwrap_or_default(); + Ok(parent.join(format!(".{name}.{}-{}{suffix}", std::process::id(), NEXT.fetch_add(1, std::sync::atomic::Ordering::Relaxed)))) +} + +/// The name `side` was made from, if it is a [`side_path`] name with `suffix`. +fn side_of<'a>(side: &'a str, suffix: &str) -> Option<&'a str> { + let rest = side.strip_prefix('.')?.strip_suffix(suffix)?; + let (name, ids) = rest.rsplit_once('.')?; + let (pid, n) = ids.split_once('-')?; + let digits = |s: &str| !s.is_empty() && s.bytes().all(|b| b.is_ascii_digit()); + (!name.is_empty() && digits(pid) && digits(n)).then_some(name) +} + +/// The side paths with `suffix` in `folder` made from `of`. +fn side_entries(folder: &Path, of: &Path, suffix: &str) -> Vec { + let Some(name) = of.file_name().and_then(|n| n.to_str()) else { return Vec::new() }; + std::fs::read_dir(folder) + .into_iter() + .flatten() + .flatten() + .filter(|e| e.file_name().to_str().and_then(|n| side_of(n, suffix)) == Some(name)) + .map(|e| e.path()) + .collect() +} + +/// Move the file or folder `from` to `to` without ever replacing anything at `to`. On one volume +/// a folder is renamed (Windows refuses an existing target; elsewhere only an empty folder could +/// be replaced) and a file is hard-linked, which fails on an existing target, and then unlinked +/// at `from`. Otherwise (another volume, e.g. a roaming profile redirected to a network share +/// while `%LOCALAPPDATA%` stays local) it is copied ([`move_across`]). +fn move_path(from: &Path, to: &Path) -> std::io::Result<()> { + if std::fs::symlink_metadata(to).is_ok() { + return Err(already_exists(to)); + } + let meta = std::fs::symlink_metadata(from)?; + let moved = if meta.is_dir() { std::fs::rename(from, to) } else { std::fs::hard_link(from, to).and_then(|()| std::fs::remove_file(from)) }; + match moved { + Ok(()) => Ok(()), + Err(e) if matches!(e.kind(), std::io::ErrorKind::AlreadyExists | std::io::ErrorKind::DirectoryNotEmpty) => Err(e), + Err(_) => move_across(from, to), + } +} + +/// Move `from` to `to` by copying. `from` is first renamed aside on its own volume +/// (`..-.held`, [`hold`]), so a process still writing to the old path (another +/// launch that couldn't take the lock, an older PhotoCraft) starts a new `from` there instead of +/// adding to what is copied, and nothing it writes is removed. The held original is copied to a +/// staged path ([`stage_copy`]), published without replacing anything ([`publish`]), and only +/// then removed. If the copy or publish fails, the held original is put back ([`put_back`]). +fn move_across(from: &Path, to: &Path) -> std::io::Result<()> { + let held = hold(from)?; + match stage_copy(&held, to).and_then(|staged| publish(&staged, to)) { + Ok(()) => { + let removed = if std::fs::symlink_metadata(&held)?.is_dir() { std::fs::remove_dir_all(&held) } else { std::fs::remove_file(&held) }; + if let Err(e) = removed { + log::warn!("copied {} to {} but couldn't remove {}: {e}", from.display(), to.display(), held.display()); + } + Ok(()) + } + Err(e) => { + put_back(&held, from); + Err(e) + } + } +} + +/// Rename `from` aside on its own volume (see [`move_across`]); returns where it is now. +fn hold(from: &Path) -> std::io::Result { + let held = side_path(from, HELD)?; + std::fs::rename(from, &held)?; + Ok(held) +} + +/// Return the held original `held` to `original`. If `original` has been made again meanwhile (a +/// writer that kept using the old path), the held folder's entries move into it without replacing +/// anything ([`move_entries`]); whatever can't move stays held, logged, and is tried again under +/// the next move's lock ([`adopt_locked`] puts back what a process that ended mid-move held). +/// Nothing held is ever removed with data in it. +fn put_back(held: &Path, original: &Path) { + if std::fs::symlink_metadata(original).is_err() && std::fs::rename(held, original).is_ok() { + return; + } + if held.is_dir() && original.is_dir() { + move_entries(held, original); + if std::fs::remove_dir(held).is_ok() { + return; + } + } + log::warn!("{} is kept aside: {} exists", held.display(), original.display()); +} + +/// Copy `from` to a staging path beside `to` that this call creates exclusively, so nothing +/// another process made is ever written to or cleaned up. On failure the staged copy is removed. +fn stage_copy(from: &Path, to: &Path) -> std::io::Result { + let staged = side_path(to, STAGED)?; + let is_dir = std::fs::symlink_metadata(from)?.is_dir(); + let copied = if is_dir { copy_tree(from, &staged, 0) } else { copy_file(from, &staged) }; + if let Err(e) = copied { + // `copy_tree` and `copy_file` create `staged` exclusively, and everything under a + // fresh folder is new: `AlreadyExists` means `staged` was someone else's to keep. + if e.kind() != std::io::ErrorKind::AlreadyExists { + let _ = if is_dir { std::fs::remove_dir_all(&staged) } else { std::fs::remove_file(&staged) }; + } + return Err(e); + } + Ok(staged) +} + +/// Remove copies a process left staged in `folder` when it ended mid-move: disposable, since the +/// original is still in place or held aside. Called only under the migration lock, which every +/// staging happens under. Links are never followed. +fn remove_stale_staging(folder: &Path) { + let Ok(entries) = std::fs::read_dir(folder) else { return }; + for entry in entries.flatten() { + if !entry.file_name().to_str().is_some_and(|n| side_of(n, STAGED).is_some()) { + continue; + } + let removed = match entry.file_type() { + Ok(t) if t.is_dir() => std::fs::remove_dir_all(entry.path()), + Ok(t) if t.is_file() => std::fs::remove_file(entry.path()), + _ => continue, + }; + if let Err(e) = removed { + log::warn!("couldn't remove the stale staged copy {}: {e}", entry.path().display()); + } + } +} + +/// Give the staged copy its final name `to` without replacing anything there (see [`move_path`] +/// for the folder and file cases). On failure the staged copy is removed and `to` is untouched. +fn publish(staged: &Path, to: &Path) -> std::io::Result<()> { + let is_dir = std::fs::symlink_metadata(staged)?.is_dir(); + let published = if std::fs::symlink_metadata(to).is_ok() { + Err(already_exists(to)) + } else if is_dir { + std::fs::rename(staged, to) + } else { + std::fs::hard_link(staged, to).and_then(|()| std::fs::remove_file(staged)) + }; + if published.is_err() { + let _ = if is_dir { std::fs::remove_dir_all(staged) } else { std::fs::remove_file(staged) }; + } + published +} + +/// Copy the folder `from` to the new folder `to` (created here; an existing `to` is an error): +/// plain files and folders only (a link or other special file is refused), at most +/// [`MAX_COPY_DEPTH`] levels. +fn copy_tree(from: &Path, to: &Path, depth: u32) -> std::io::Result<()> { + if depth > MAX_COPY_DEPTH { + return Err(std::io::Error::other(format!("{} is nested too deep", from.display()))); + } + std::fs::create_dir(to)?; + for entry in std::fs::read_dir(from)? { + let entry = entry?; + let target = to.join(entry.file_name()); + if entry.file_type()?.is_dir() { + copy_tree(&entry.path(), &target, depth.saturating_add(1))?; + } else { + copy_file(&entry.path(), &target)?; + } + } + Ok(()) +} + +/// Copy one plain file (a link or other special file is refused) to the new file `to` (an +/// existing `to` is an error), flushed to disk. +fn copy_file(from: &Path, to: &Path) -> std::io::Result<()> { + if !std::fs::symlink_metadata(from)?.file_type().is_file() { + return Err(std::io::Error::other(format!("{} is not a plain file", from.display()))); + } + let mut src = std::fs::File::open(from)?; + let mut dst = std::fs::OpenOptions::new().write(true).create_new(true).open(to)?; + std::io::copy(&mut src, &mut dst)?; + dst.sync_all() +} + /// Resolve the data directory from the environment (`env`) and the executable's folder. pub fn resolve(env: impl Fn(&str) -> Option, exe_dir: Option<&Path>) -> DataDir { if let Some(d) = env("PHOTOCRAFT_CONFIG_DIR").filter(|d| !d.is_empty()) { @@ -223,6 +599,300 @@ mod tests { let _ = std::fs::remove_dir_all(&root); } + /// #5648: on Windows, autosaves go to the machine-local `%LOCALAPPDATA%`, not the roaming + /// profile; an override or portable folder keeps them inside itself. + #[test] + fn autosaves_stay_out_of_the_roaming_profile() { + let root = temp("recovery-local"); + let settings = root.join("appdata").join("Photocraft"); + let local = root.join("local"); + let env = env_of(&[("LOCALAPPDATA", &local)]); + let platform = DataDir { dir: Some(settings.clone()), mode: Mode::Platform, warning: None }; + let expected = if cfg!(windows) { local.join("Photocraft").join("Recovery") } else { settings.join("Recovery") }; + assert_eq!(resolve_recovery(&platform, &env), Some(expected)); + for mode in [Mode::Override, Mode::Portable] { + let d = DataDir { dir: Some(settings.clone()), mode, warning: None }; + assert_eq!(resolve_recovery(&d, &env), Some(settings.join("Recovery")), "{mode:?}"); + } + // No (or an empty) LOCALAPPDATA: beside the settings, as before. + let empty = PathBuf::new(); + assert_eq!(resolve_recovery(&platform, env_of(&[("LOCALAPPDATA", &empty)])), Some(settings.join("Recovery"))); + assert_eq!(resolve_recovery(&platform, |_| None), Some(settings.join("Recovery"))); + let nowhere = DataDir { dir: None, mode: Mode::Platform, warning: None }; + assert_eq!(resolve_recovery(&nowhere, |_| None), None); + let _ = std::fs::remove_dir_all(&root); + } + + /// An autosave bundle and its sidecar, as `format::Autosaver` writes them. + fn autosave(dir: &Path, key: &str) { + std::fs::create_dir_all(dir.join(format!("{key}.pcraft"))).unwrap(); + std::fs::write(dir.join(format!("{key}.pcraft")).join("manifest.json"), key).unwrap(); + std::fs::write(dir.join(format!("{key}.json")), key).unwrap(); + } + + #[test] + fn autosaves_from_the_old_folder_move_to_the_new_one() { + let root = temp("recovery-move"); + let legacy = root.join("roaming").join("Recovery"); + let dir = root.join("local").join("Photocraft").join("Recovery"); + autosave(&legacy, "a"); + assert_eq!(adopt_legacy_recovery(&legacy, &dir), dir); + assert!(!legacy.exists(), "the old folder is gone"); + assert_eq!(std::fs::read_to_string(dir.join("a.pcraft").join("manifest.json")).unwrap(), "a"); + assert!(dir.join("a.json").is_file()); + let _ = std::fs::remove_dir_all(&root); + } + + #[test] + fn autosaves_merge_into_an_existing_folder_without_overwriting() { + let root = temp("recovery-merge"); + let legacy = root.join("roaming").join("Recovery"); + let dir = root.join("local").join("Recovery"); + autosave(&legacy, "old"); + autosave(&legacy, "both"); + // A temp file of an interrupted write moves along, for the recovery store's sweep. + let temp_file = ".old.json.4294967294-0.pcsave.tmp"; + std::fs::write(legacy.join(temp_file), b"partial").unwrap(); + std::fs::create_dir_all(&dir).unwrap(); + std::fs::write(dir.join("both.json"), "newer").unwrap(); + assert_eq!(adopt_legacy_recovery(&legacy, &dir), dir); + assert!(dir.join(temp_file).is_file() && !legacy.join(temp_file).exists()); + assert!(dir.join("old.pcraft").is_dir() && dir.join("old.json").is_file(), "a whole autosave moves"); + assert_eq!(std::fs::read_to_string(dir.join("both.json")).unwrap(), "newer", "never overwritten"); + assert!(!dir.join("both.pcraft").exists(), "a clashing autosave isn't split"); + assert!(legacy.join("both.pcraft").is_dir() && legacy.join("both.json").is_file(), "it stays behind whole"); + let _ = std::fs::remove_dir_all(&root); + } + + #[test] + fn autosaves_that_cannot_move_keep_being_used_where_they_are() { + let root = temp("recovery-stuck"); + let legacy = root.join("roaming").join("Recovery"); + autosave(&legacy, "a"); + // A file where the new folder's parent should be: nothing can be created there. + std::fs::write(root.join("blocked"), b"in the way").unwrap(); + let dir = root.join("blocked").join("Recovery"); + assert_eq!(adopt_legacy_recovery(&legacy, &dir), legacy); + assert!(legacy.join("a.json").is_file(), "nothing lost"); + let _ = std::fs::remove_dir_all(&root); + } + + #[test] + fn an_empty_old_folder_is_removed_even_where_it_cannot_move() { + let root = temp("recovery-empty"); + let legacy = root.join("roaming").join("Recovery"); + std::fs::create_dir_all(&legacy).unwrap(); + std::fs::write(root.join("blocked"), b"in the way").unwrap(); + let dir = root.join("blocked").join("Recovery"); + assert_eq!(adopt_legacy_recovery(&legacy, &dir), dir, "nothing to keep listed"); + assert!(!legacy.exists(), "the empty old folder is gone"); + let _ = std::fs::remove_dir_all(&root); + } + + /// Where a rename can't cross volumes (a roaming profile on a network share), an autosave + /// is copied whole, nested folders included, and only then removed. + #[test] + fn a_move_across_volumes_copies_then_removes() { + let root = temp("recovery-copy"); + let legacy = root.join("roaming").join("Recovery"); + autosave(&legacy, "a"); + std::fs::create_dir_all(legacy.join("a.pcraft").join("tiles")).unwrap(); + std::fs::write(legacy.join("a.pcraft").join("tiles").join("t0.zst"), b"tile").unwrap(); + let dir = root.join("local").join("Recovery"); + std::fs::create_dir_all(root.join("local")).unwrap(); + move_across(&legacy, &dir).unwrap(); + assert!(!legacy.exists(), "the original is removed after the copy"); + assert_eq!(side_leftovers(&root.join("roaming")), 0, "nothing is left held aside"); + assert_eq!(std::fs::read_to_string(dir.join("a.pcraft").join("manifest.json")).unwrap(), "a"); + assert_eq!(std::fs::read(dir.join("a.pcraft").join("tiles").join("t0.zst")).unwrap(), b"tile"); + assert_eq!(std::fs::read_to_string(dir.join("a.json")).unwrap(), "a"); + // One file on its own, as the per-autosave moves do. + move_across(&dir.join("a.json"), &root.join("a.json")).unwrap(); + assert!(!dir.join("a.json").exists() && root.join("a.json").is_file()); + assert_eq!(side_leftovers(&dir) + side_leftovers(&root), 0); + let _ = std::fs::remove_dir_all(&root); + } + + #[test] + fn a_failed_copy_puts_the_original_back_and_leaves_no_partial_copy() { + let root = temp("recovery-copy-fail"); + let legacy = root.join("Recovery"); + autosave(&legacy, "a"); + // Deeper than any bundle: the copy gives up part way. + let mut deep = legacy.join("a.pcraft"); + for _ in 0..=MAX_COPY_DEPTH { + deep = deep.join("d"); + } + std::fs::create_dir_all(&deep).unwrap(); + let dir = root.join("Local"); + assert!(move_across(&legacy, &dir).is_err()); + assert!(!dir.exists(), "nothing is published"); + assert_eq!(side_leftovers(&root), 0, "no staged copy, nothing left held aside"); + assert!(legacy.join("a.json").is_file() && deep.is_dir(), "the original is back in place"); + // A target that already exists is never copied into or removed. + std::fs::create_dir_all(&dir).unwrap(); + std::fs::write(dir.join("keep"), b"mine").unwrap(); + assert!(move_across(&legacy.join("a.json"), &dir).is_err()); + assert_eq!(std::fs::read(dir.join("keep")).unwrap(), b"mine"); + assert!(legacy.join("a.json").is_file()); + assert_eq!(side_leftovers(&root) + side_leftovers(&legacy), 0); + let _ = std::fs::remove_dir_all(&root); + } + + /// Staged (`.moving`) and held (`.held`) side paths left in `dir`. + fn side_leftovers(dir: &Path) -> usize { + std::fs::read_dir(dir) + .unwrap() + .flatten() + .filter(|e| e.file_name().to_str().is_some_and(|n| side_of(n, STAGED).is_some() || side_of(n, HELD).is_some())) + .count() + } + + /// The target appears after the copy is staged (another process got there first): publishing + /// fails, that process's target keeps its contents, the staged copy is removed and the + /// original is left as it was. Folder and file alike. + #[test] + fn a_target_that_appears_while_copying_is_never_replaced_or_removed() { + let root = temp("recovery-race"); + let legacy = root.join("roaming").join("Recovery"); + autosave(&legacy, "a"); + let local = root.join("local"); + std::fs::create_dir_all(&local).unwrap(); + // A whole folder. + let dir = local.join("Recovery"); + let staged = stage_copy(&legacy, &dir).unwrap(); + autosave(&dir, "theirs"); + let e = publish(&staged, &dir).unwrap_err(); + assert_eq!(e.kind(), std::io::ErrorKind::AlreadyExists); + assert_eq!(std::fs::read_to_string(dir.join("theirs.json")).unwrap(), "theirs", "their folder is intact"); + assert!(!dir.join("a.json").exists(), "nothing was copied into it"); + assert!(!staged.exists() && side_leftovers(&local) == 0, "only the staged copy is removed"); + assert!(legacy.join("a.json").is_file(), "the original is untouched"); + // One file. + let to = dir.join("a.json"); + let staged = stage_copy(&legacy.join("a.json"), &to).unwrap(); + std::fs::write(&to, "theirs").unwrap(); + assert_eq!(publish(&staged, &to).unwrap_err().kind(), std::io::ErrorKind::AlreadyExists); + assert_eq!(std::fs::read_to_string(&to).unwrap(), "theirs"); + assert!(!staged.exists() && legacy.join("a.json").is_file()); + let _ = std::fs::remove_dir_all(&root); + } + + /// A process holds the lock and is copying the old folder (held aside, copy staged) when a + /// second launch gives up waiting. The second launch must get the new folder, never the old + /// one, and what it writes there survives the first one finishing: the publish refuses the + /// now-existing folder and the original is put back, so both autosaves stay (#6412 review). + #[test] + fn a_launch_that_times_out_never_writes_where_a_move_removes() { + let root = temp("recovery-timeout"); + let legacy = root.join("roaming").join("Recovery"); + autosave(&legacy, "old"); + let local = root.join("local"); + std::fs::create_dir_all(&local).unwrap(); + let dir = local.join("Recovery"); + // First process: takes the lock, sets the original aside and stages its copy, then pauses. + let first = std::fs::OpenOptions::new().create(true).truncate(false).write(true).open(local.join(MIGRATION_LOCK)).unwrap(); + first.lock().unwrap(); + let held = hold(&legacy).unwrap(); + let staged = stage_copy(&held, &dir).unwrap(); + // Second launch: times out and writes a newer autosave where it was told to. + let chosen = adopt_legacy_recovery_waiting(&legacy, &dir, std::time::Duration::ZERO); + assert_eq!(chosen, dir, "a launch that couldn't get the lock never uses the old folder"); + autosave(&chosen, "new"); + // First process resumes: publish, and on failure put the original back (as `move_across`). + assert_eq!(publish(&staged, &dir).unwrap_err().kind(), std::io::ErrorKind::AlreadyExists); + put_back(&held, &legacy); + drop(first); + assert_eq!(std::fs::read_to_string(dir.join("new.json")).unwrap(), "new", "the newer autosave survives"); + assert_eq!(std::fs::read_to_string(legacy.join("old.json")).unwrap(), "old", "the old one is back in place"); + assert_eq!(side_leftovers(&local) + side_leftovers(&root.join("roaming")), 0); + // The next launch merges them. + assert_eq!(adopt_legacy_recovery_waiting(&legacy, &dir, std::time::Duration::ZERO), dir); + assert!(dir.join("old.json").is_file() && dir.join("new.json").is_file() && !legacy.exists()); + let _ = std::fs::remove_dir_all(&root); + } + + /// A writer that still uses the old path while it is being copied (an older PhotoCraft) + /// starts a new folder there; the move removes only the original it held aside. + #[test] + fn a_write_to_the_old_path_during_a_copy_is_not_removed() { + let root = temp("recovery-old-writer"); + let legacy = root.join("roaming").join("Recovery"); + autosave(&legacy, "old"); + let local = root.join("local"); + std::fs::create_dir_all(&local).unwrap(); + let dir = local.join("Recovery"); + let held = hold(&legacy).unwrap(); + let staged = stage_copy(&held, &dir).unwrap(); + autosave(&legacy, "new"); + publish(&staged, &dir).unwrap(); + std::fs::remove_dir_all(&held).unwrap(); + assert_eq!(std::fs::read_to_string(dir.join("old.json")).unwrap(), "old"); + assert_eq!(std::fs::read_to_string(legacy.join("new.json")).unwrap(), "new", "the new write is untouched"); + let _ = std::fs::remove_dir_all(&root); + } + + /// What a process killed mid-move left is tidied at the next move: a held original is put + /// back (it may be the only copy) and moved, a staged copy is removed; other dot-files and + /// look-alike names are not touched. + #[test] + fn a_killed_move_is_tidied_next_time() { + let root = temp("recovery-stale"); + let roaming = root.join("roaming"); + let legacy = roaming.join("Recovery"); + autosave(&legacy, "a"); + let held = hold(&legacy).unwrap(); + assert!(!legacy.exists() && held.is_dir()); + let local = root.join("local"); + let dir = local.join("Recovery"); + let stale = local.join(".Recovery.4294967294-3.moving"); + autosave(&stale, "half"); + let kept = [local.join(".Recovery.moving"), local.join(".keep.1-x.moving"), local.join("notes.txt")]; + for k in &kept { + std::fs::write(k, b"x").unwrap(); + } + assert_eq!(adopt_legacy_recovery_waiting(&legacy, &dir, std::time::Duration::ZERO), dir); + assert_eq!(std::fs::read_to_string(dir.join("a.json")).unwrap(), "a", "the held original was put back and moved"); + assert!(!stale.exists(), "the stale staged copy is gone"); + assert_eq!(side_leftovers(&roaming), 0); + assert!(kept.iter().all(|k| k.is_file()), "only side names are touched"); + assert_eq!(side_of(".a.json.12-0.moving", STAGED), Some("a.json")); + assert!(side_of("a.json.12-0.moving", STAGED).is_none() && side_of(".a.json.-0.moving", STAGED).is_none()); + assert!(side_of(".a.json.12-0.held", STAGED).is_none() && side_of(".a.json.12-0.held", HELD) == Some("a.json")); + let _ = std::fs::remove_dir_all(&root); + } + + /// While another process holds the move lock, a launch moves nothing and uses the new folder; + /// once the lock is released, the move happens. + #[test] + fn a_move_waits_for_another_process_holding_the_lock() { + let root = temp("recovery-lock"); + let legacy = root.join("roaming").join("Recovery"); + autosave(&legacy, "a"); + let dir = root.join("local").join("Recovery"); + std::fs::create_dir_all(root.join("local")).unwrap(); + let held = std::fs::OpenOptions::new().create(true).truncate(false).write(true).open(root.join("local").join(MIGRATION_LOCK)).unwrap(); + held.lock().unwrap(); + assert_eq!(adopt_legacy_recovery_waiting(&legacy, &dir, std::time::Duration::ZERO), dir); + assert!(legacy.join("a.json").is_file() && !dir.exists(), "nothing moved under another's lock"); + drop(held); + assert_eq!(adopt_legacy_recovery_waiting(&legacy, &dir, std::time::Duration::ZERO), dir); + assert!(dir.join("a.json").is_file() && !legacy.exists()); + let _ = std::fs::remove_dir_all(&root); + } + + #[test] + fn no_old_folder_or_the_same_folder_changes_nothing() { + let root = temp("recovery-noop"); + let dir = root.join("Recovery"); + assert_eq!(adopt_legacy_recovery(&root.join("missing"), &dir), dir); + assert!(!dir.exists(), "nothing is created up front"); + autosave(&dir, "a"); + assert_eq!(adopt_legacy_recovery(&dir, &dir), dir); + assert!(dir.join("a.json").is_file()); + let _ = std::fs::remove_dir_all(&root); + } + #[test] fn missing_exe_dir_or_env_never_panics() { let d = resolve(|_| None, None); diff --git a/apps/photocraft/src/services.rs b/apps/photocraft/src/services.rs index 6c34fda33a..d4a1611a18 100644 --- a/apps/photocraft/src/services.rs +++ b/apps/photocraft/src/services.rs @@ -264,8 +264,8 @@ fn block_on(future: impl Future) -> T { } /// Per-user settings directory: `PHOTOCRAFT_CONFIG_DIR`, else `/PhotoCraftData` in -/// portable mode, else the platform convention. Everything the app persists lives under it; see -/// [`crate::app_dirs`]. +/// portable mode, else the platform convention. Everything the app persists lives under it +/// except, on Windows, crash-recovery autosaves; see [`crate::app_dirs`]. pub fn config_dir() -> Option { crate::app_dirs::config_dir() } @@ -280,8 +280,10 @@ pub fn presets_dir() -> Option { config_dir().map(|d| d.join("Presets")) } +/// Crash-recovery autosaves: under the settings directory, except on Windows, where they stay +/// out of the roaming profile (see [`crate::app_dirs::recovery_dir`]). fn recovery_dir() -> Option { - config_dir().map(|d| d.join("Recovery")) + crate::app_dirs::recovery_dir() } /// Write `bytes` crash-safely (temp file beside the target, fsync, rename, directory fsync; see diff --git a/docs/control-protocol.md b/docs/control-protocol.md index 14d416f19b..15334ac3a2 100644 --- a/docs/control-protocol.md +++ b/docs/control-protocol.md @@ -246,7 +246,9 @@ log text file. GPU on/off and the GPU tile size apply at the next launch. The desktop app stores them in `preferences.json` in the platform config directory (macOS `~/Library/Application Support/Photocraft`, Windows `%APPDATA%\Photocraft`, Linux `$XDG_CONFIG_HOME/photocraft`; override with `PHOTOCRAFT_CONFIG_DIR`); autosaves go to its -`Recovery` folder, and the window size and position, the dock width and floating-panel geometry +`Recovery` folder (on Windows to `%LOCALAPPDATA%\Photocraft\Recovery`, out of the roaming +profile; autosaves left by earlier versions move there at startup), and the window size and +position, the dock width and floating-panel geometry to `ui.ron` beside it (a file holding values that would crash startup is discarded). In portable mode (a `portable.txt` or `PhotoCraft.portable` file beside the executable, as in the Windows portable zip) that directory is `PhotoCraftData` next to the executable instead. The web build keeps them in `localStorage`. A save writes only the values diff --git a/packaging/windows/portable.txt b/packaging/windows/portable.txt index 9372e88b71..853874bc2c 100644 --- a/packaging/windows/portable.txt +++ b/packaging/windows/portable.txt @@ -2,8 +2,8 @@ PhotoCraft portable mode While this file sits next to photocraft.exe, PhotoCraft keeps its preferences, brush presets, crash-recovery autosaves and startup state in the PhotoCraftData folder beside it, and writes -nothing to %APPDATA%. Copy the whole folder to move PhotoCraft with its settings. +nothing to %APPDATA% or %LOCALAPPDATA%. Copy the whole folder to move PhotoCraft with its settings. -Delete this file to use the normal per-user settings folder (%APPDATA%\Photocraft) instead. -If PhotoCraftData can't be written (for example on a read-only drive), PhotoCraft says so and -uses %APPDATA%\Photocraft instead. +Delete this file to use the normal per-user folders instead: %APPDATA%\Photocraft for settings +and %LOCALAPPDATA%\Photocraft\Recovery for crash-recovery autosaves. If PhotoCraftData can't be +written (for example on a read-only drive), PhotoCraft says so and uses those folders instead. From fbcb70b317337d17614c94db08250de280793a34 Mon Sep 17 00:00:00 2001 From: Zuujis <160198409+zuujiscom@users.noreply.github.com> Date: Fri, 16 Oct 2026 21:19:32 -0500 Subject: [PATCH 68/85] Save: ask before overwriting a file another app changed (#6596) (#6652) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * Save: ask before overwriting a file another app changed (#6596) [WIP] Co-Authored-By: Claude Opus 5.5 * Save: ask before overwriting a file another app changed (#6596) File › Save now notes the size and modification time of a document's own file when it is opened or saved, and checks them again before writing back. If another app changed the file in between, a prompt asks whether to replace it (Cancel keeps both; Save As saves elsewhere) instead of silently overwriting the other app's changes. Co-Authored-By: Claude Opus 5.5 --------- Co-authored-by: Claude Opus 5.5 --- apps/photocraft/src/services.rs | 5 ++ crates/ui-egui/src/dialogs.rs | 4 ++ crates/ui-egui/src/disk_change_prompt.rs | 57 ++++++++++++++++ crates/ui-egui/src/file_dialog/tests.rs | 87 ++++++++++++++++++++++++ crates/ui-egui/src/file_open.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 | 3 + crates/ui-egui/src/i18n/hu.tsv | 2 + crates/ui-egui/src/i18n/id.tsv | 3 + crates/ui-egui/src/i18n/it.tsv | 2 + crates/ui-egui/src/i18n/ja.tsv | 3 + crates/ui-egui/src/i18n/ko.tsv | 3 + crates/ui-egui/src/i18n/nl.tsv | 2 + crates/ui-egui/src/i18n/pl.tsv | 3 + crates/ui-egui/src/i18n/pt-br.tsv | 2 + crates/ui-egui/src/i18n/ru.tsv | 3 + crates/ui-egui/src/i18n/sr.tsv | 2 + crates/ui-egui/src/i18n/tr.tsv | 2 + 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 | 47 ++++++++++++- crates/ui-egui/src/menus.rs | 6 +- 27 files changed, 255 insertions(+), 3 deletions(-) create mode 100644 crates/ui-egui/src/disk_change_prompt.rs diff --git a/apps/photocraft/src/services.rs b/apps/photocraft/src/services.rs index d4a1611a18..d4e7a01553 100644 --- a/apps/photocraft/src/services.rs +++ b/apps/photocraft/src/services.rs @@ -506,6 +506,11 @@ pub fn native(automation: Option) -> save_file: Some(Arc::new(save_file)), file_dialog: Some(Box::new(show_file_dialog)), write: Some(Box::new(|path: &str, bytes: &[u8]| write_atomic(Path::new(path), bytes))), + file_stamp: Some(Box::new(|path: &str| { + // Follows a symlink, as Save writes through it to the file it points at. + let meta = std::fs::metadata(path).ok()?; + Some(photocraft_ui_egui::FileStamp { len: meta.len(), modified: meta.modified().ok() }) + })), automation_read, automation_write, automation_open_pcraft, diff --git a/crates/ui-egui/src/dialogs.rs b/crates/ui-egui/src/dialogs.rs index 182b4be8c7..43b4f3d3f9 100644 --- a/crates/ui-egui/src/dialogs.rs +++ b/crates/ui-egui/src/dialogs.rs @@ -321,6 +321,7 @@ pub fn show(app: &mut PhotocraftApp, ctx: &egui::Context) { DialogKind::Command if crate::channel_options_ui::owns_new_spot(&fields) => crate::channel_options_ui::new_spot_body(ui, &mut fields), DialogKind::Command if crate::shape_dialog::owns(&fields) => crate::shape_dialog::body(app, ui, &mut fields), DialogKind::Command if crate::delete_layer_prompt::owns(&fields) => crate::delete_layer_prompt::body(ui, &mut fields), + DialogKind::Command if crate::disk_change_prompt::owns(&fields) => crate::disk_change_prompt::body(ui, &fields), DialogKind::Command if crate::actions::owns_insert_stop(&fields) => crate::actions::insert_stop_body(ui, &mut fields), DialogKind::Command if crate::new_layer_ui::owns(&fields) => crate::new_layer_ui::body(ui, &mut fields), DialogKind::Command if crate::mask_trash_prompt::owns(&fields) => crate::mask_trash_prompt::body(ui, &fields), @@ -404,6 +405,8 @@ pub fn show(app: &mut PhotocraftApp, ctx: &egui::Context) { tl!("Create") } else if crate::delete_layer_prompt::owns(&d.fields) { tl!("Delete") + } else if crate::disk_change_prompt::owns(&d.fields) { + tl!("Replace") } else if d.fields.contains_key("__export") { tl!("Export") } else { @@ -617,6 +620,7 @@ pub fn confirm(app: &mut PhotocraftApp, id: u64) -> Result { DialogKind::Command if crate::channel_options_ui::owns_new_spot(&d.fields) => crate::channel_options_ui::confirm_new_spot(app, &d.fields), DialogKind::Command if crate::shape_dialog::owns(&d.fields) => crate::shape_dialog::confirm(app, &d.fields), DialogKind::Command if crate::delete_layer_prompt::owns(&d.fields) => crate::delete_layer_prompt::confirm(app, &d.fields), + DialogKind::Command if crate::disk_change_prompt::owns(&d.fields) => crate::disk_change_prompt::confirm(app, &d.fields), DialogKind::Command if crate::actions::owns_insert_stop(&d.fields) => crate::actions::insert_stop_confirm(app, &d.fields), DialogKind::Command if crate::new_layer_ui::owns(&d.fields) => crate::new_layer_ui::confirm(app, &d.fields), DialogKind::Command if crate::mask_trash_prompt::owns(&d.fields) => crate::mask_trash_prompt::confirm(app, &d.fields), diff --git a/crates/ui-egui/src/disk_change_prompt.rs b/crates/ui-egui/src/disk_change_prompt.rs new file mode 100644 index 0000000000..dd9b8215b9 --- /dev/null +++ b/crates/ui-egui/src/disk_change_prompt.rs @@ -0,0 +1,57 @@ +//! File › Save asks before replacing the document's own file when another app changed it after +//! the document was opened from it or last saved to it (#6596). Replace writes the document over +//! it anyway; Cancel leaves the file and the document as they are, and File › Save As keeps both. +//! +//! The prompt is an ordinary dialog in the UI state: `ui.dialog.confirm` replaces the file and +//! `ui.dialog.cancel` keeps it. Recorded actions and automation saves don't ask. + +use photocraft_doc::DocId; +use serde_json::{Map, Value, json}; + +use crate::PhotocraftApp; +use crate::state::DialogKind; + +const MARK: &str = "__diskChanged"; + +pub fn owns(fields: &Map) -> bool { + fields.contains_key(MARK) +} + +/// Opens the prompt instead of saving `doc` to its own file at `path` when another app changed +/// that file; `None` saves as usual. `flat` is a flat file confirmed in the Save dialog, which +/// is saved (and recorded) the way [`PhotocraftApp::save_dialog_flow`] does it. +pub(crate) fn intercept(app: &mut PhotocraftApp, doc: DocId, path: &str, flat: bool) -> Option> { + if !app.changed_on_disk(doc, path) { + return None; + } + if app.ui.dialogs.iter().any(|d| owns(&d.fields)) { + return Some(Ok(json!({"confirmationPending": true}))); + } + let name = crate::file_open::display_name(path); + let message = crate::i18n::fmt(tl!("{name} was changed by another app after it was opened or saved here."), &[("name", name.as_str())]); + let fields = json!({MARK: true, "__label": "Save", "message": message, "document": doc.0, "path": path, "flat": flat}); + let id = app.ui.open_dialog(DialogKind::Command, fields.as_object().cloned().unwrap_or_default()); + Some(Ok(json!({"dialog": id, "confirmationPending": true}))) +} + +pub fn body(ui: &mut egui::Ui, fields: &Map) { + ui.label(fields.get("message").and_then(Value::as_str).unwrap_or_default()); + ui.add_space(6.0); + ui.label(tl!("Replace it with this document, or cancel and use File › Save As to keep both.")); +} + +/// Replace: the save the prompt held back. +pub fn confirm(app: &mut PhotocraftApp, fields: &Map) -> Result { + let doc = fields.get("document").and_then(Value::as_u64).map(DocId).ok_or(tl!("The document is closed"))?; + let path = fields.get("path").and_then(Value::as_str).unwrap_or_default().to_string(); + let flat = fields.get("flat").and_then(Value::as_bool) == Some(true); + app.with_document(doc, |app| { + if flat { + let r = app.save_to(path.clone(), None)?; + app.journal_as_plain_save(&path); + Ok(r) + } else { + app.save_in_place(path) + } + }) +} diff --git a/crates/ui-egui/src/file_dialog/tests.rs b/crates/ui-egui/src/file_dialog/tests.rs index 9e4c878f68..6a3ccfcf9e 100644 --- a/crates/ui-egui/src/file_dialog/tests.rs +++ b/crates/ui-egui/src/file_dialog/tests.rs @@ -1010,3 +1010,90 @@ fn render_video_menu_asks_for_directory_and_writes_frames_only_after_confirmatio assert!(!app.file_dialog_open()); std::fs::remove_dir_all(&dir).unwrap(); } + +/// How many times each path was written, standing in for its size and modification time. +type Disk = Rc>>; + +/// Gives `app` file stamps from a fake disk that its own writes update too. +fn on_disk(app: &mut PhotocraftApp) -> Disk { + let disk = Disk::default(); + let (read, wrote) = (disk.clone(), disk.clone()); + app.services.file_stamp = Some(Box::new(move |path| read.borrow().get(path).map(|&n| crate::FileStamp { len: n, modified: None }))); + let mut write = app.services.write.take().unwrap(); + app.services.write = Some(Box::new(move |path, bytes| { + write(path, bytes)?; + *wrote.borrow_mut().entry(path.to_string()).or_default() += 1; + Ok(()) + })); + disk +} + +fn changed_by_another_app(disk: &Disk, path: &str) { + *disk.borrow_mut().entry(path.to_string()).or_default() += 1; +} + +fn disk_prompt(app: &PhotocraftApp) -> Option<&crate::state::Dialog> { + app.ui.dialogs.iter().find(|d| crate::disk_change_prompt::owns(&d.fields)) +} + +#[test] +fn save_asks_before_replacing_a_file_another_app_changed() { + let ctx = egui::Context::default(); + let (mut app, _open, written) = app(); + let disk = on_disk(&mut app); + changed_by_another_app(&disk, "/pics/a.psd"); + app.opened_from("/pics/a.psd"); + app.run("layer.new.layer", json!({})).unwrap(); + // Unchanged on disk: Save writes at once. + menus::invoke(&mut app, &ctx, "file.save", json!({})).unwrap(); + assert_eq!(*written.borrow(), ["/pics/a.psd"]); + assert!(disk_prompt(&app).is_none()); + + // Our own save isn't a change; another app's write after it is. + changed_by_another_app(&disk, "/pics/a.psd"); + app.run("layer.new.layer", json!({})).unwrap(); + let r = menus::invoke(&mut app, &ctx, "file.save", json!({})).unwrap(); + assert_eq!(r["confirmationPending"], true); + assert_eq!(written.borrow().len(), 1, "nothing is written before the answer"); + let prompt = disk_prompt(&app).expect("Save asks first"); + assert!(prompt.fields["message"].as_str().unwrap().contains("a.psd")); + + // Cancel leaves the file and the unsaved document alone. + let id = prompt.id; + app.ui.close_dialog(id); + assert_eq!(written.borrow().len(), 1); + assert!(app.session.active().unwrap().is_dirty()); + + // Replace writes it, and the next Save doesn't ask again. + let id = menus::invoke(&mut app, &ctx, "file.save", json!({})).unwrap()["dialog"].as_u64().unwrap(); + crate::dialogs::confirm(&mut app, id).unwrap(); + assert_eq!(written.borrow().len(), 2); + assert!(!app.session.active().unwrap().is_dirty()); + app.run("layer.new.layer", json!({})).unwrap(); + menus::invoke(&mut app, &ctx, "file.save", json!({})).unwrap(); + assert_eq!(written.borrow().len(), 3); + assert!(disk_prompt(&app).is_none()); +} + +#[test] +fn save_of_a_flat_file_asks_after_the_dialog_when_another_app_changed_it() { + let ctx = egui::Context::default(); + // Lowercase: the Save dialog lowercases a typed extension by default. + let path = "/pics/My image.jpg"; + let (mut app, open, written) = jpeg_app(None, false); + let disk = on_disk(&mut app); + changed_by_another_app(&disk, path); + app.opened_from(path); + app.run("image.adjustments.invert", json!({})).unwrap(); + changed_by_another_app(&disk, path); + menus::invoke(&mut app, &ctx, "file.save", json!({})).unwrap(); + app.poll_file_dialog(&ctx, None); + answer(&open, Some(FileDialogAnswer::SaveTo(path.into()))); + app.poll_file_dialog(&ctx, None); + assert!(written.borrow().is_empty(), "the changed file is not replaced before the answer"); + let id = disk_prompt(&app).expect("Save asks first").id; + crate::dialogs::confirm(&mut app, id).unwrap(); + assert_eq!(*written.borrow(), [path]); + assert!(!app.session.active().unwrap().is_dirty()); + assert_eq!(app.session.journal.last().map(|s| s.0.as_str()), Some("file.save"), "still recorded as a plain Save"); +} diff --git a/crates/ui-egui/src/file_open.rs b/crates/ui-egui/src/file_open.rs index 13105a321d..d752f7ce76 100644 --- a/crates/ui-egui/src/file_open.rs +++ b/crates/ui-egui/src/file_open.rs @@ -89,6 +89,8 @@ impl PhotocraftApp { && !st.source_read_only { st.path = Some(path.to_string()); + let doc = st.doc.id; + self.remember_stamp(doc, path); } self.push_recent(path); } diff --git a/crates/ui-egui/src/i18n/cs.tsv b/crates/ui-egui/src/i18n/cs.tsv index f3fb8c8c40..f50c0249b7 100644 --- a/crates/ui-egui/src/i18n/cs.tsv +++ b/crates/ui-egui/src/i18n/cs.tsv @@ -3816,3 +3816,5 @@ navigator Zoom Zvětšení 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) + {name} was changed by another app after it was opened or saved here. Soubor {name} změnila jiná aplikace poté, co byl zde otevřen nebo uložen. + Replace it with this document, or cancel and use File › Save As to keep both. Nahraďte ho tímto dokumentem, nebo zrušte a použijte Soubor › Uložit jako, abyste zachovali obojí. diff --git a/crates/ui-egui/src/i18n/de.tsv b/crates/ui-egui/src/i18n/de.tsv index 48ebfba678..b1ea8977e2 100644 --- a/crates/ui-egui/src/i18n/de.tsv +++ b/crates/ui-egui/src/i18n/de.tsv @@ -3210,3 +3210,5 @@ curves Smooth Glätten 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) + {name} was changed by another app after it was opened or saved here. {name} wurde von einer anderen App geändert, nachdem es hier geöffnet oder gespeichert wurde. + Replace it with this document, or cancel and use File › Save As to keep both. Ersetze die Datei durch dieses Dokument oder brich ab und nutze Datei › Speichern unter, um beide zu behalten. diff --git a/crates/ui-egui/src/i18n/el.tsv b/crates/ui-egui/src/i18n/el.tsv index f2c67c2c41..8229806307 100644 --- a/crates/ui-egui/src/i18n/el.tsv +++ b/crates/ui-egui/src/i18n/el.tsv @@ -3908,3 +3908,5 @@ curves Smooth Εξομάλυνση 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 = Αυτόματο) + {name} was changed by another app after it was opened or saved here. Το {name} άλλαξε από άλλη εφαρμογή αφού ανοίχτηκε ή αποθηκεύτηκε εδώ. + Replace it with this document, or cancel and use File › Save As to keep both. Αντικαταστήστε το με αυτό το έγγραφο ή ακυρώστε και χρησιμοποιήστε Αρχείο › Αποθήκευση ως για να κρατήσετε και τα δύο. diff --git a/crates/ui-egui/src/i18n/es.tsv b/crates/ui-egui/src/i18n/es.tsv index 45804fa2a4..6f3d85002b 100644 --- a/crates/ui-egui/src/i18n/es.tsv +++ b/crates/ui-egui/src/i18n/es.tsv @@ -4170,3 +4170,5 @@ navigator Zoom Zoom 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) + {name} was changed by another app after it was opened or saved here. Otra aplicación cambió {name} después de abrirlo o guardarlo aquí. + Replace it with this document, or cancel and use File › Save As to keep both. Reemplázalo con este documento, o cancela y usa Archivo › Guardar como para conservar ambos. diff --git a/crates/ui-egui/src/i18n/fr.tsv b/crates/ui-egui/src/i18n/fr.tsv index 3e3451e096..4a819b0e1b 100644 --- a/crates/ui-egui/src/i18n/fr.tsv +++ b/crates/ui-egui/src/i18n/fr.tsv @@ -3213,3 +3213,6 @@ curves Smooth Lisser 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) + Replace Remplacer + {name} was changed by another app after it was opened or saved here. {name} a été modifié par une autre application après avoir été ouvert ou enregistré ici. + Replace it with this document, or cancel and use File › Save As to keep both. Remplacez-le par ce document, ou annulez et utilisez Fichier › Enregistrer sous pour garder les deux. diff --git a/crates/ui-egui/src/i18n/hu.tsv b/crates/ui-egui/src/i18n/hu.tsv index 9dc9a47fbc..86a6b30acb 100644 --- a/crates/ui-egui/src/i18n/hu.tsv +++ b/crates/ui-egui/src/i18n/hu.tsv @@ -4127,3 +4127,5 @@ cameraRaw Yellow Sárga 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) + {name} was changed by another app after it was opened or saved here. A(z) {name} fájlt egy másik alkalmazás módosította, miután itt megnyitották vagy mentették. + Replace it with this document, or cancel and use File › Save As to keep both. Cserélje le erre a dokumentumra, vagy válassza a Mégse gombot, és a Fájl › Mentés másként paranccsal tartsa meg mindkettőt. diff --git a/crates/ui-egui/src/i18n/id.tsv b/crates/ui-egui/src/i18n/id.tsv index 81e850f293..49777913c1 100644 --- a/crates/ui-egui/src/i18n/id.tsv +++ b/crates/ui-egui/src/i18n/id.tsv @@ -3200,3 +3200,6 @@ curves Smooth Haluskan 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) + Replace Ganti + {name} was changed by another app after it was opened or saved here. {name} diubah oleh aplikasi lain setelah dibuka atau disimpan di sini. + Replace it with this document, or cancel and use File › Save As to keep both. Ganti dengan dokumen ini, atau batalkan dan gunakan Berkas › Simpan Sebagai untuk menyimpan keduanya. diff --git a/crates/ui-egui/src/i18n/it.tsv b/crates/ui-egui/src/i18n/it.tsv index c097ded29d..f1144c0fdd 100644 --- a/crates/ui-egui/src/i18n/it.tsv +++ b/crates/ui-egui/src/i18n/it.tsv @@ -4329,3 +4329,5 @@ shape Appearance Aspetto 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) + {name} was changed by another app after it was opened or saved here. {name} è stato modificato da un'altra app dopo essere stato aperto o salvato qui. + Replace it with this document, or cancel and use File › Save As to keep both. Sostituiscilo con questo documento, oppure annulla e usa File › Salva con nome per tenerli entrambi. diff --git a/crates/ui-egui/src/i18n/ja.tsv b/crates/ui-egui/src/i18n/ja.tsv index b460965128..351699ea89 100644 --- a/crates/ui-egui/src/i18n/ja.tsv +++ b/crates/ui-egui/src/i18n/ja.tsv @@ -3884,3 +3884,6 @@ curves Smooth スムーズ 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 = 自動) + Replace 置き換え + {name} was changed by another app after it was opened or saved here. {name} は、ここで開くか保存した後に別のアプリで変更されました。 + Replace it with this document, or cancel and use File › Save As to keep both. この書類で置き換えるか、キャンセルしてファイル › 名前を付けて保存で両方を残してください。 diff --git a/crates/ui-egui/src/i18n/ko.tsv b/crates/ui-egui/src/i18n/ko.tsv index 361cef2e95..3eaf143979 100644 --- a/crates/ui-egui/src/i18n/ko.tsv +++ b/crates/ui-egui/src/i18n/ko.tsv @@ -3386,3 +3386,6 @@ curves Smooth 매끄럽게 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 = 자동) + Replace 대치 + {name} was changed by another app after it was opened or saved here. {name}이(가) 여기서 열거나 저장한 뒤 다른 앱에서 변경되었습니다. + Replace it with this document, or cancel and use File › Save As to keep both. 이 문서로 대치하거나, 취소하고 파일 › 다른 이름으로 저장을 사용해 둘 다 보관하세요. diff --git a/crates/ui-egui/src/i18n/nl.tsv b/crates/ui-egui/src/i18n/nl.tsv index f1ac67961e..09dd568009 100644 --- a/crates/ui-egui/src/i18n/nl.tsv +++ b/crates/ui-egui/src/i18n/nl.tsv @@ -3968,3 +3968,5 @@ curves Smooth Vloeiend maken 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) + {name} was changed by another app after it was opened or saved here. {name} is door een andere app gewijzigd nadat het hier is geopend of bewaard. + Replace it with this document, or cancel and use File › Save As to keep both. Vervang het door dit document, of annuleer en gebruik Bestand › Opslaan als om beide te houden. diff --git a/crates/ui-egui/src/i18n/pl.tsv b/crates/ui-egui/src/i18n/pl.tsv index 04eb15609d..c4827aa829 100644 --- a/crates/ui-egui/src/i18n/pl.tsv +++ b/crates/ui-egui/src/i18n/pl.tsv @@ -3806,3 +3806,6 @@ curves Smooth Wygładź 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) + Replace Zastąp + {name} was changed by another app after it was opened or saved here. Plik {name} został zmieniony przez inną aplikację po otwarciu lub zapisaniu go tutaj. + Replace it with this document, or cancel and use File › Save As to keep both. Zastąp go tym dokumentem albo anuluj i użyj Plik › Zapisz jako, aby zachować oba. diff --git a/crates/ui-egui/src/i18n/pt-br.tsv b/crates/ui-egui/src/i18n/pt-br.tsv index 8d6b88fb54..f96542d642 100644 --- a/crates/ui-egui/src/i18n/pt-br.tsv +++ b/crates/ui-egui/src/i18n/pt-br.tsv @@ -3998,3 +3998,5 @@ layerLabel No Color Sem cor 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) + {name} was changed by another app after it was opened or saved here. {name} foi alterado por outro app depois de ser aberto ou salvo aqui. + Replace it with this document, or cancel and use File › Save As to keep both. Substitua-o por este documento, ou cancele e use Arquivo › Salvar como para manter os dois. diff --git a/crates/ui-egui/src/i18n/ru.tsv b/crates/ui-egui/src/i18n/ru.tsv index 9d733f11a7..f49200b973 100644 --- a/crates/ui-egui/src/i18n/ru.tsv +++ b/crates/ui-egui/src/i18n/ru.tsv @@ -3178,3 +3178,6 @@ curves Smooth Сгладить 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 = автоматически) + Replace Заменить + {name} was changed by another app after it was opened or saved here. Файл {name} был изменён другим приложением после того, как его открыли или сохранили здесь. + Replace it with this document, or cancel and use File › Save As to keep both. Замените его этим документом или отмените и используйте Файл › Сохранить как, чтобы сохранить оба. diff --git a/crates/ui-egui/src/i18n/sr.tsv b/crates/ui-egui/src/i18n/sr.tsv index d384c9badc..c207facb72 100644 --- a/crates/ui-egui/src/i18n/sr.tsv +++ b/crates/ui-egui/src/i18n/sr.tsv @@ -4281,3 +4281,5 @@ shape Appearance Izgled 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) + {name} was changed by another app after it was opened or saved here. Datoteku {name} je izmenila druga aplikacija nakon što je ovde otvorena ili sačuvana. + Replace it with this document, or cancel and use File › Save As to keep both. Zameni je ovim dokumentom ili otkaži i koristi Datoteka › Sačuvaj kao da zadržiš obe. diff --git a/crates/ui-egui/src/i18n/tr.tsv b/crates/ui-egui/src/i18n/tr.tsv index 5c35aba71c..a8aa0c3c58 100644 --- a/crates/ui-egui/src/i18n/tr.tsv +++ b/crates/ui-egui/src/i18n/tr.tsv @@ -3938,3 +3938,5 @@ cameraData Lens Lens 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) + {name} was changed by another app after it was opened or saved here. {name}, burada açıldıktan veya kaydedildikten sonra başka bir uygulama tarafından değiştirildi. + Replace it with this document, or cancel and use File › Save As to keep both. Bu belgeyle değiştirin ya da iptal edip ikisini de korumak için Dosya › Farklı Kaydet'i kullanın. diff --git a/crates/ui-egui/src/i18n/uk.tsv b/crates/ui-egui/src/i18n/uk.tsv index cb6f883eb2..48fb60343c 100644 --- a/crates/ui-egui/src/i18n/uk.tsv +++ b/crates/ui-egui/src/i18n/uk.tsv @@ -3907,3 +3907,6 @@ curves Smooth Згладити 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 = автоматично) + Replace Замінити + {name} was changed by another app after it was opened or saved here. Файл {name} змінила інша програма після того, як його тут відкрили або зберегли. + Replace it with this document, or cancel and use File › Save As to keep both. Замініть його цим документом або скасуйте й скористайтеся Файл › Зберегти як, щоб зберегти обидва. diff --git a/crates/ui-egui/src/i18n/vi.tsv b/crates/ui-egui/src/i18n/vi.tsv index e2e55ab1ba..f33ef15011 100644 --- a/crates/ui-egui/src/i18n/vi.tsv +++ b/crates/ui-egui/src/i18n/vi.tsv @@ -3694,3 +3694,6 @@ curves Smooth Làm mượt 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) + Replace Thay thế + {name} was changed by another app after it was opened or saved here. {name} đã bị một ứng dụng khác thay đổi sau khi được mở hoặc lưu ở đây. + Replace it with this document, or cancel and use File › Save As to keep both. Thay thế bằng tài liệu này, hoặc hủy và dùng Tệp › Lưu thành để giữ cả hai. diff --git a/crates/ui-egui/src/i18n/zh-hans.tsv b/crates/ui-egui/src/i18n/zh-hans.tsv index eebeabaa6c..75eacc994d 100644 --- a/crates/ui-egui/src/i18n/zh-hans.tsv +++ b/crates/ui-egui/src/i18n/zh-hans.tsv @@ -2824,3 +2824,6 @@ curves Smooth 平滑 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 = 自动) + Replace 替换 + {name} was changed by another app after it was opened or saved here. {name} 在此处打开或存储后已被其他应用更改。 + Replace it with this document, or cancel and use File › Save As to keep both. 用此文档替换它,或取消并使用“文件 › 另存为”以保留两者。 diff --git a/crates/ui-egui/src/i18n/zh-hant.tsv b/crates/ui-egui/src/i18n/zh-hant.tsv index b35556b6fe..95a11dc593 100644 --- a/crates/ui-egui/src/i18n/zh-hant.tsv +++ b/crates/ui-egui/src/i18n/zh-hant.tsv @@ -2809,3 +2809,6 @@ curves Smooth 平滑 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 = 自動) + Replace 取代 + {name} was changed by another app after it was opened or saved here. {name} 在此處開啟或儲存後已被其他 App 更改。 + Replace it with this document, or cancel and use File › Save As to keep both. 以此文件取代它,或取消並使用「檔案 › 另存新檔」以保留兩者。 diff --git a/crates/ui-egui/src/lib.rs b/crates/ui-egui/src/lib.rs index 4c8d64832b..04c37282e6 100644 --- a/crates/ui-egui/src/lib.rs +++ b/crates/ui-egui/src/lib.rs @@ -75,6 +75,7 @@ pub mod dialogs; pub mod direct_select; pub mod direction; pub mod discard_ui; +pub mod disk_change_prompt; pub mod distort_ui; pub mod doc_props_ui; pub mod dock; @@ -350,6 +351,15 @@ pub(crate) fn plain_raster(doc: &Document) -> Option<&photocraft_doc::Layer> { /// Encode a document: (file bytes, warnings about anything approximated or dropped). pub type ExportFn = Box Result<(Vec, Vec), String>>; pub type WriteFn = Box Result<(), String>>; +/// What a file on disk looked like when a document last read or wrote it: File › Save asks +/// before replacing a file another app changed since (#6596). +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub struct FileStamp { + pub len: u64, + pub modified: Option, +} +/// The [`FileStamp`] of the file at a path, `None` when it can't be read. +pub type FileStampFn = Box Option>; /// Encode a document and write it to a path, reporting the stage to the job; returns the export /// warnings. Runs on a worker thread (see [`Services::save_file`]). It must call /// [`jobs_ui::SaveCtl::commit`] right before replacing the file, and write nothing if that fails. @@ -447,6 +457,9 @@ pub struct Services { pub file_dialog: Option, /// Write bytes to a path (native) or trigger a download (web). pub write: Option, + /// Size and modification time of a file (native); `None` on the web, where Save never + /// writes back to a file. + pub file_stamp: Option, /// Encode and write in one step on a worker thread, so a large save doesn't freeze the /// window (#2017). With background jobs on, saves use it instead of `export` and `write`. pub save_file: Option, @@ -788,6 +801,9 @@ pub struct PhotocraftApp { /// Where each document was last saved or exported to through a dialog (#1826): its next Save /// As or export dialog starts there rather than beside the document. Session-only. pub(crate) save_dirs: HashMap, + /// Each document's own file as it was when the document was opened from it or saved to it + /// (see [`FileStamp`]). + pub(crate) disk_stamps: HashMap, /// A save to an RGB Targa parked behind the Targa Options prompt (see `targa_options_ui`). pub(crate) targa_options: Option, /// Files read but not opened yet: the first waits on its profile prompt, the rest behind it @@ -937,6 +953,7 @@ impl PhotocraftApp { file_dialog: None, tiff_options: None, save_dirs: HashMap::new(), + disk_stamps: HashMap::new(), targa_options: None, held_opens: Default::default(), allow_close: false, @@ -1439,6 +1456,13 @@ impl PhotocraftApp { self.pick_save(&suggested, move |app, path| { app.with_document(doc, |app| { let own = app.session.active().and_then(|st| st.path.clone()); + // Confirming the document's own file: ask first if another app changed it (#6596). + if plain_save + && own.as_deref() == Some(path.as_str()) + && let Some(r) = crate::disk_change_prompt::intercept(app, doc, &path, true) + { + return r; + } let r = app.save_to(path.clone(), tga_bits)?; if plain_save && own.as_deref() == Some(path.as_str()) { app.journal_as_plain_save(&path); @@ -1449,7 +1473,7 @@ impl PhotocraftApp { } /// [`Self::save_as_with`] once the path is known. - fn save_to(&mut self, path: String, tga_bits: Option) -> Result { + pub(crate) fn save_to(&mut self, path: String, tga_bits: Option) -> Result { // An RGB Targa asks for its bits per pixel, unless the caller chose them; the save // continues from the prompt (still a copy when it would be one without the prompt), // which records the step once answered. @@ -1493,7 +1517,7 @@ impl PhotocraftApp { } /// Re-label the Save As just journaled for `path` as File › Save (see [`Self::save_dialog_flow`]). - fn journal_as_plain_save(&mut self, path: &str) { + pub(crate) fn journal_as_plain_save(&mut self, path: &str) { if let Some(last) = self.session.journal.last_mut() && last.0 == "file.saveAs" && last.1.get("path").and_then(Value::as_str) == Some(path) @@ -1634,6 +1658,7 @@ impl PhotocraftApp { st.saved_to(path.to_string()); // The job locked the document, but record the revision that was written. st.saved_revision = revision; + app.remember_stamp(doc, path); } // "Save Document" script events and File › Generate › Image Assets. if hooks @@ -1653,6 +1678,9 @@ impl PhotocraftApp { if hooks { self.push_recent(path); } + } else if self.disk_stamps.get(&doc).is_some_and(|(own, _)| own == path) { + // A copy written over the document's own file is this app's change too. + self.remember_stamp(doc, path); } self.ui.status_error = false; let what = crate::i18n::fmt(tl!("Saved {name}"), &[("name", file_open::display_name(path).as_str())]); @@ -1660,6 +1688,21 @@ impl PhotocraftApp { self.sync_views(); } + /// Note how `doc`'s own file at `path` looks now that the document read or wrote it. + pub(crate) fn remember_stamp(&mut self, doc: photocraft_doc::DocId, path: &str) { + match self.services.file_stamp.as_ref().and_then(|stamp| stamp(path)) { + Some(now) => self.disk_stamps.insert(doc, (path.to_string(), now)), + None => self.disk_stamps.remove(&doc), + }; + } + + /// Another app changed `doc`'s own file at `path` since the document was opened from it or + /// last saved to it. A file that is gone, or one never stamped, counts as unchanged. + pub(crate) fn changed_on_disk(&self, doc: photocraft_doc::DocId, path: &str) -> bool { + let Some((own, then)) = self.disk_stamps.get(&doc) else { return false }; + own == path && self.services.file_stamp.as_ref().and_then(|stamp| stamp(path)).is_some_and(|now| now != *then) + } + /// TIFF Options or Targa Options is waiting on an answer before a save is written. pub(crate) fn save_options_open(&self) -> bool { self.tiff_options.is_some() || self.targa_options.is_some() diff --git a/crates/ui-egui/src/menus.rs b/crates/ui-egui/src/menus.rs index f2baba7cf8..d322e26418 100644 --- a/crates/ui-egui/src/menus.rs +++ b/crates/ui-egui/src/menus.rs @@ -231,7 +231,11 @@ pub(crate) fn invoke_unguarded(app: &mut PhotocraftApp, ctx: &egui::Context, id: None => { let own = app.session.active().and_then(|d| d.path.clone().map(|p| (photocraft_engine::file_cmds::holds_at(&d.doc, &p), p, d))); match own { - Some((true, path, _)) => app.save_in_place(path), + Some((true, path, d)) => { + // Another app changed the file since it was opened or saved: ask first (#6596). + let doc = d.doc.id; + crate::disk_change_prompt::intercept(app, doc, &path, false).unwrap_or_else(|| app.save_in_place(path)) + } Some((false, path, d)) if photocraft_engine::file_cmds::saves_in_place(&path) => { // Its own file is layered but can't hold what the document has now. let losses = photocraft_engine::file_cmds::losses_at(&d.doc, &path); From 7c44b2bc152bd2c6269942ee4be09f6779be9f12 Mon Sep 17 00:00:00 2001 From: aiongg Date: Sat, 17 Oct 2026 02:19:47 +0000 Subject: [PATCH 69/85] Build: use craft-docpath from craft-libs --- Cargo.lock | 1 + Cargo.toml | 2 +- 2 files changed, 2 insertions(+), 1 deletion(-) diff --git a/Cargo.lock b/Cargo.lock index e1206c2277..6c3369fdec 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1225,6 +1225,7 @@ dependencies = [ [[package]] name = "craft-docpath" version = "0.1.0" +source = "git+https://github.com/aiongg/craft-libs?rev=12281d07fedff5c955bb68344fb287aa5d87971e#12281d07fedff5c955bb68344fb287aa5d87971e" [[package]] name = "craft-print" diff --git a/Cargo.toml b/Cargo.toml index f215b0d049..1d65deac3a 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -39,7 +39,7 @@ photocraft-tablet = { path = "crates/tablet" } # external craft-print = { git = "https://github.com/storytold/craft-libs", rev = "3d37da389a3618ab9b76109da11416e9ca4ea719" } -craft-docpath = { path = "../secfix-craft-libs/crates/docpath" } +craft-docpath = { git = "https://github.com/aiongg/craft-libs", version = "0.1.0", rev = "12281d07fedff5c955bb68344fb287aa5d87971e" } thiserror = "2" serde = { version = "1", features = ["derive", "rc"] } serde_json = "1" From bd3b5d9192e6a0a5fa70d3f780a8abe19679328d Mon Sep 17 00:00:00 2001 From: wwew <145259726+wwewtech@users.noreply.github.com> Date: Sat, 17 Oct 2026 06:20:03 +0400 Subject: [PATCH 70/85] fix(automation): refuse a group/world-readable control-token file (#4966) (#5459) --- crates/automation/src/security.rs | 43 ++++++++++++++++++++++++++++++- 1 file changed, 42 insertions(+), 1 deletion(-) diff --git a/crates/automation/src/security.rs b/crates/automation/src/security.rs index 7928420b12..6198f22851 100644 --- a/crates/automation/src/security.rs +++ b/crates/automation/src/security.rs @@ -135,7 +135,27 @@ pub fn repeat_auth_reply(line: &str) -> Option { } fn read_token_file(path: &Path) -> Result { - let token = std::fs::read_to_string(path).map_err(|e| AutomationError::Io(format!("{}: {e}", path.display())))?; + // Validate what we actually opened, not just the name: an existing control-token file must be + // a regular file that no other local principal can read. A freshly created one is 0600 + // (`create_token_file`); an operator-supplied one is checked here so a group- or world-readable + // token is refused rather than trusted as the control credential (#4966). + let mut file = std::fs::File::open(path).map_err(|e| AutomationError::Io(format!("{}: {e}", path.display())))?; + let meta = file.metadata().map_err(|e| AutomationError::Io(format!("{}: {e}", path.display())))?; + if !meta.is_file() { + return Err(AutomationError::Io(format!("{}: control token file is not a regular file", path.display()))); + } + #[cfg(unix)] + { + use std::os::unix::fs::MetadataExt; + if meta.mode() & 0o077 != 0 { + return Err(AutomationError::Io(format!( + "{}: control token file is readable by group or others (mode {:o}); restrict it to its owner (e.g. chmod 600)", + path.display(), + meta.mode() & 0o777 + ))); + } + } + let token = std::io::read_to_string(&mut file).map_err(|e| AutomationError::Io(format!("{}: {e}", path.display())))?; let token = token.trim().to_owned(); validate_token(&token)?; Ok(token) @@ -433,6 +453,27 @@ mod tests { sender.join().unwrap(); } + #[cfg(unix)] + #[test] + fn an_existing_token_file_must_be_owner_only() { + use std::os::unix::fs::PermissionsExt; + let dir = std::env::temp_dir().join(format!("photocraft-token-perm-{}", std::process::id())); + std::fs::create_dir_all(&dir).unwrap(); + let path = dir.join("token.txt"); + let token = "0123456789abcdef".repeat(4); // 64 hex chars, passes validate_token + std::fs::write(&path, &token).unwrap(); + + // A group- or world-readable token is refused: the control credential must not be shared (#4966). + std::fs::set_permissions(&path, std::fs::Permissions::from_mode(0o644)).unwrap(); + assert!(read_token_file(&path).is_err(), "a 0644 token file must be refused"); + + // Owner-only is opened, read and validated. + std::fs::set_permissions(&path, std::fs::Permissions::from_mode(0o600)).unwrap(); + assert_eq!(read_token_file(&path).unwrap(), token, "a 0600 token file must be read"); + + std::fs::remove_dir_all(&dir).ok(); + } + #[test] fn connection_limiter_releases_capacity() { let limiter = ConnectionLimiter::new(1); From 5f65579ecb44047c2a619036c18ff273202e1389 Mon Sep 17 00:00:00 2001 From: Nicolas Falesy <112726756+nicolasfalesy@users.noreply.github.com> Date: Fri, 16 Oct 2026 22:20:22 -0400 Subject: [PATCH 71/85] =?UTF-8?q?perf(panorama):=20Photomerge=204=C3=97=20?= =?UTF-8?q?faster,=20bit=20for=20bit=20the=20same=20(#6489)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit N is the whole algo-side Photomerge pipeline on 3 × 8 MP photos (register at the engine's scale with the Auto layout, gain + vignette fit, seams, multi-band blend), 16 threads: 1042 ms before, 264 ms after, 3.9×, rounded to 4×. The engine's own glue (reading regions, building weights, writing layers) is not in this number. Registration and multi-band blending ran almost entirely on one thread per image (four images on 16 threads), so most of the machine sat idle. Every output is bit for bit what the serial code produced: a probe that hashes the public Photomerge entry points (detect_many, match_pairs, align for every layout, photometric, seam_labels, multiband at 1 to 7 levels with 1 and 3 channels, with holes in the alpha, with images sticking out of the canvas, plus direct harris / describe calls) matches on all 349 outputs before and after, on 4 × 2 MP, 3 × 8 MP, 97×61, 211×157, 333×251, 320×240 two-image and 40×35 sets, at 16, 3 and 1 threads. - features `blur` (Harris window and BRIEF smoothing): the row pass runs rows in parallel; the column pass cuts the image into bands of 64 columns, each holding the `&mut` row segments of its own columns (no unsafe), run in parallel. Every column keeps its own f32 running sum, started from its first window summed in order and updated in the same order, so only the loop nest changes. - `harris`: Sobel, the corner response and the non-maximum scan are parallel by rows (the candidates are fully ordered by score, y, x afterwards, so arrival order is irrelevant). `describe`: one descriptor per corner in parallel, kept in corner order. - `reduce`, `expand`, `push_pull`: per-row maps in parallel. `reduce`'s column pass adds five whole rows in tap order (same per-element sum, now vectorised); `expand` works out its source columns once and truncates instead of calling libm `floor` (the clamped position is never negative); `push_pull` builds each coarse row from its two fine rows in raster order, which is the order the serial scatter-add used. - `multiband`: grid fill per image, Laplacian difference, band accumulation (every row walks the bands in the original order), normalisation, collapse and the coverage union are parallel. Float additions are never reordered. Left serial on purpose: the peak fold in `harris` (about 1 ms, an order-free max but not worth the proof), the corner spacing grid, `seam_labels` (a dynamic-programming path whose rows depend on the previous one), `photometric` sample gathering, and the pyramid levels themselves (each level needs the one above). Median of 5 (8 MP rows: 7), release, ms, interleaved before/after runs. The laptop was busy with unrelated builds the whole time (load average 11 to 18 on 16 threads), so the 1-thread rows are noisy (a single thread is time-sliced against them) and rows can move by 10 to 20 percent. | Case (probe) | before (16 thr) | after (16 thr) | speedup | before (1 thr) | after (1 thr) | |--------------------------------|----------------:|---------------:|--------:|---------------:|--------------:| | register, 4 × 2 MP | 154 | 60 | 2.6× | 336 | 167 | | multiband, 4 × 2 MP, 3 ch | 407 | 108 | 3.8× | 591 | 407 | | multiband, 4 × 2 MP, 1 ch | 307 | 73 | 4.2× | 813 | 566 | | whole pipeline, 4 × 2 MP | 437 | 160 | 2.7× | 716 | 504 | | register, 3 × 8 MP (full size) | 327 | 124 | 2.6× | 978 | 391 | | multiband, 3 × 8 MP, 3 ch | 2348 | 916 | 2.6× | 1742 | 1150 | | whole pipeline, 3 × 8 MP | 1042 | 264 | 3.9× | 2264 | 1280 | The 8 MP multiband row at 16 threads is the noisiest (minimum of 7: 959 to 224 ms, 4.3×); the whole-pipeline row is steady. Max RSS for the 3 × 8 MP whole pipeline (the probe's inputs are about 1.05 GB of it): 2.79 GB before, 2.79 GB after (three runs each). Tests: the serial `blur`, `reduce`, `expand`, `push_pull` and `multiband` are kept in the test modules as references and compared bit for bit over sizes that cross every band and row boundary (1×1, 1-wide, 1-high, odd sizes, 63 / 64 / 65 / 129 columns, radius 0 to wider than the image, holes, partial and zero weights, ROIs outside the canvas, 1 to 7 levels). Hard-coded hashes recorded from the old code cover multiband, push_pull, reduce + expand everywhere (plain f32 arithmetic), and harris + describe on Linux x86-64 (they go through the platform's hypot / ln / cos / atan2 / sin_cos). Claude-Session: https://claude.ai/code/session_01AfRHB1wHUjrJx6agBYGnVu Co-authored-by: Claude Opus 5.5 --- crates/algo/src/features.rs | 313 ++++++++++++++--- crates/algo/src/panorama.rs | 677 +++++++++++++++++++++++++++++++----- 2 files changed, 846 insertions(+), 144 deletions(-) diff --git a/crates/algo/src/features.rs b/crates/algo/src/features.rs index ab98de757e..e8e45bcaf2 100644 --- a/crates/algo/src/features.rs +++ b/crates/algo/src/features.rs @@ -60,25 +60,98 @@ impl Rng { } } +/// Columns per band of the vertical blur pass: 64 f32 = 256 bytes = four cache lines per row, so +/// walking down a band touches whole lines, and the band's working set stays in cache. +const BLUR_BAND: usize = 64; + +/// Box blur of radius `r` (edges clamped): a running f32 sum along rows, then along columns. +/// +/// Both passes are parallel and give bit for bit what the one-line-at-a-time version gave: every +/// output sums its window in the same order. Rows are independent. For the column pass, the +/// image is cut into bands of columns; each column keeps its own f32 running sum, started from +/// its first window summed in order and updated in the same order, so only the loop nest changes +/// (the inner loop runs across a band's columns and vectorises), never any column's arithmetic. fn blur(w: usize, h: usize, v: &[f32], r: usize) -> Vec { - let pass = |src: &[f32], horizontal: bool| -> Vec { - let mut dst = vec![0.0f32; src.len()]; - let (n, m) = if horizontal { (h, w) } else { (w, h) }; - for line in 0..n { - let at = |k: i64| { - let k = k.clamp(0, m as i64 - 1) as usize; - if horizontal { src[line * w + k] } else { src[k * w + line] } - }; - let mut acc: f32 = (-(r as i64)..=r as i64).map(at).sum(); - for k in 0..m { - let i = if horizontal { line * w + k } else { k * w + line }; - dst[i] = acc / (2 * r + 1) as f32; - acc += at(k as i64 + r as i64 + 1) - at(k as i64 - r as i64); + if w == 0 || h == 0 { + return Vec::new(); + } + let den = (2 * r + 1) as f32; + let mut tmp = vec![0.0f32; w * h]; + par_rows(&mut tmp, w, |line, dst| { + let src = &v[line * w..(line + 1) * w]; + let at = |k: i64| src[k.clamp(0, w as i64 - 1) as usize]; + let mut acc: f32 = (-(r as i64)..=r as i64).map(at).sum(); + for (k, o) in dst.iter_mut().enumerate() { + *o = acc / den; + acc += at(k as i64 + r as i64 + 1) - at(k as i64 - r as i64); + } + }); + let mut dst = vec![0.0f32; w * h]; + // One list of row segments per band, each segment a `&mut` slice of `dst`: no `unsafe`. + let mut bands: Vec> = (0..w.div_ceil(BLUR_BAND)).map(|_| Vec::with_capacity(h)).collect(); + for row in dst.chunks_exact_mut(w) { + for (band, seg) in bands.iter_mut().zip(row.chunks_mut(BLUR_BAND)) { + band.push(seg); + } + } + let src = &tmp; + let run = |bi: usize, segs: &mut [&mut [f32]], acc: &mut Vec| { + let (x0, bw) = (bi * BLUR_BAND, segs.first().map_or(0, |s| s.len())); + let row = |k: i64| { + let o = (k.clamp(0, h as i64 - 1) as usize) * w + x0; + &src[o..o + bw] + }; + // The first window, summed in order from the same start as `Iterator::sum` uses. + acc.clear(); + acc.resize(bw, std::iter::empty::().sum()); + for k in -(r as i64)..=r as i64 { + for (t, &x) in acc.iter_mut().zip(row(k)) { + *t += x; + } + } + for (k, seg) in segs.iter_mut().enumerate() { + let (add, sub) = (row(k as i64 + r as i64 + 1), row(k as i64 - r as i64)); + for (((o, t), &p), &m) in seg.iter_mut().zip(acc.iter_mut()).zip(add).zip(sub) { + *o = *t / den; + *t += p - m; } } - dst }; - pass(&pass(v, true), false) + #[cfg(not(target_arch = "wasm32"))] + { + use rayon::prelude::*; + bands.into_par_iter().enumerate().for_each_init(Vec::new, |acc, (bi, mut segs)| run(bi, &mut segs, acc)); + } + #[cfg(target_arch = "wasm32")] + { + let mut acc = Vec::new(); + for (bi, mut segs) in bands.into_iter().enumerate() { + run(bi, &mut segs, &mut acc); + } + } + dst +} + +/// Runs `f(y, row)` over the `w`-wide rows of `buf`, in parallel on native targets. +fn par_rows(buf: &mut [f32], w: usize, f: impl Fn(usize, &mut [f32]) + Sync + Send) { + #[cfg(not(target_arch = "wasm32"))] + { + use rayon::prelude::*; + buf.par_chunks_mut(w).enumerate().for_each(|(y, row)| f(y, row)); + } + #[cfg(target_arch = "wasm32")] + buf.chunks_mut(w).enumerate().for_each(|(y, row)| f(y, row)); +} + +/// [`par_rows`] over three row-aligned buffers in lockstep. +fn par_rows3(a: &mut [f32], b: &mut [f32], c: &mut [f32], w: usize, f: impl Fn(usize, &mut [f32], &mut [f32], &mut [f32]) + Sync + Send) { + #[cfg(not(target_arch = "wasm32"))] + { + use rayon::prelude::*; + a.par_chunks_mut(w).zip(b.par_chunks_mut(w)).zip(c.par_chunks_mut(w)).enumerate().for_each(|(y, ((a, b), c))| f(y, a, b, c)); + } + #[cfg(target_arch = "wasm32")] + a.chunks_mut(w).zip(b.chunks_mut(w)).zip(c.chunks_mut(w)).enumerate().for_each(|(y, ((a, b), c))| f(y, a, b, c)); } /// Harris corners: at most `max` corners at least `min_dist` pixels apart, strongest first, @@ -88,28 +161,40 @@ pub fn harris(w: usize, h: usize, img: &[f32], max: usize, min_dist: f32, valid: return Vec::new(); } let (mut xx, mut yy, mut xy) = (vec![0.0f32; w * h], vec![0.0f32; w * h], vec![0.0f32; w * h]); - for y in 1..h - 1 { + // Rows are independent (each output pixel reads only `img`), so they run in parallel. + par_rows3(&mut xx, &mut yy, &mut xy, w, |y, xxr, yyr, xyr| { + if y == 0 || y + 1 == h { + return; + } for x in 1..w - 1 { let p = |dx: i64, dy: i64| img[((y as i64 + dy) as usize) * w + (x as i64 + dx) as usize]; // Sobel. let gx = (p(1, -1) + 2.0 * p(1, 0) + p(1, 1)) - (p(-1, -1) + 2.0 * p(-1, 0) + p(-1, 1)); let gy = (p(-1, 1) + 2.0 * p(0, 1) + p(1, 1)) - (p(-1, -1) + 2.0 * p(0, -1) + p(1, -1)); - let i = y * w + x; - xx[i] = gx * gx; - yy[i] = gy * gy; - xy[i] = gx * gy; + xxr[x] = gx * gx; + yyr[x] = gy * gy; + xyr[x] = gx * gy; } - } + }); // Two box passes approximate the Gaussian window. let (xx, yy, xy) = (blur(w, h, &blur(w, h, &xx, 1), 1), blur(w, h, &blur(w, h, &yy, 1), 1), blur(w, h, &blur(w, h, &xy, 1), 1)); - let r: Vec = (0..w * h).map(|i| xx[i] * yy[i] - xy[i] * xy[i] - 0.04 * (xx[i] + yy[i]).powi(2)).collect(); + let mut r = vec![0.0f32; w * h]; + par_rows(&mut r, w, |y, row| { + for (x, o) in row.iter_mut().enumerate() { + let i = y * w + x; + *o = xx[i] * yy[i] - xy[i] * xy[i] - 0.04 * (xx[i] + yy[i]).powi(2); + } + }); let peak = r.iter().copied().fold(0.0f32, f32::max); if peak <= 0.0 { return Vec::new(); } let m = PATCH_RADIUS + 1; - let mut cand: Vec = Vec::new(); - for y in m..h - m { + // Rows are scanned in parallel and joined in row order; the sort below fully orders the + // candidates anyway (score, then y, then x), so the order they arrive in cannot matter. + let rows = par_map((h - m).saturating_sub(m), |i| { + let y = m + i; + let mut found: Vec = Vec::new(); for x in m..w - m { let v = r[y * w + x]; if v < peak * 0.01 { @@ -125,9 +210,11 @@ pub fn harris(w: usize, h: usize, img: &[f32], max: usize, min_dist: f32, valid: continue; } } - cand.push(Corner { x: x as f32, y: y as f32, score: v }); + found.push(Corner { x: x as f32, y: y as f32, score: v }); } - } + found + }); + let mut cand: Vec = rows.into_iter().flatten().collect(); cand.sort_by(|a, b| b.score.total_cmp(&a.score).then(a.y.total_cmp(&b.y)).then(a.x.total_cmp(&b.x))); // Minimum spacing via a coarse occupancy grid. let cell = min_dist.max(1.0); @@ -176,37 +263,37 @@ pub fn describe(w: usize, h: usize, img: &[f32], corners: &[Corner], upright: bo let (xi, yi) = ((x.round() as i64).clamp(0, w as i64 - 1) as usize, (y.round() as i64).clamp(0, h as i64 - 1) as usize); smooth[yi * w + xi] }; - corners - .iter() - .map(|c| { - let (s, co) = if upright { - (0.0, 1.0) - } else { - let (mut m10, mut m01) = (0.0f32, 0.0f32); - let r = PATCH_RADIUS as i64; - for dy in -r..=r { - for dx in -r..=r { - if dx * dx + dy * dy <= r * r { - let v = at(c.x + dx as f32, c.y + dy as f32); - m10 += dx as f32 * v; - m01 += dy as f32 * v; - } + // Every descriptor depends only on its own corner and the smoothed image: computed in + // parallel, kept in corner order. + par_map(corners.len(), |ci| { + let c = &corners[ci]; + let (s, co) = if upright { + (0.0, 1.0) + } else { + let (mut m10, mut m01) = (0.0f32, 0.0f32); + let r = PATCH_RADIUS as i64; + for dy in -r..=r { + for dx in -r..=r { + if dx * dx + dy * dy <= r * r { + let v = at(c.x + dx as f32, c.y + dy as f32); + m10 += dx as f32 * v; + m01 += dy as f32 * v; } } - m01.atan2(m10).sin_cos() - }; - let mut d = [0u64; 4]; - for (i, p) in pairs().iter().enumerate() { - let rot = |x: f32, y: f32| (c.x + x * co - y * s, c.y + x * s + y * co); - let (ax, ay) = rot(p[0], p[1]); - let (bx, by) = rot(p[2], p[3]); - if at(ax, ay) < at(bx, by) { - d[i / 64] |= 1 << (i % 64); - } } - d - }) - .collect() + m01.atan2(m10).sin_cos() + }; + let mut d = [0u64; 4]; + for (i, p) in pairs().iter().enumerate() { + let rot = |x: f32, y: f32| (c.x + x * co - y * s, c.y + x * s + y * co); + let (ax, ay) = rot(p[0], p[1]); + let (bx, by) = rot(p[2], p[3]); + if at(ax, ay) < at(bx, by) { + d[i / 64] |= 1 << (i % 64); + } + } + d + }) } pub fn hamming(a: &Descriptor, b: &Descriptor) -> u32 { @@ -560,3 +647,121 @@ mod tests { assert_eq!(hamming(&[0, 0, 0, 0], &[1, 3, 0, u64::MAX]), 67); } } + +/// The row / column-band parallel `blur`, `harris` and `describe` against the serial code they +/// replaced. Every output must stay bit for bit the same. +#[cfg(test)] +mod parallel_tests { + use super::*; + + /// The serial blur exactly as it was before the parallelisation. + fn ref_blur(w: usize, h: usize, v: &[f32], r: usize) -> Vec { + let pass = |src: &[f32], horizontal: bool| -> Vec { + let mut dst = vec![0.0f32; src.len()]; + let (n, m) = if horizontal { (h, w) } else { (w, h) }; + for line in 0..n { + let at = |k: i64| { + let k = k.clamp(0, m as i64 - 1) as usize; + if horizontal { src[line * w + k] } else { src[k * w + line] } + }; + let mut acc: f32 = (-(r as i64)..=r as i64).map(at).sum(); + for k in 0..m { + let i = if horizontal { line * w + k } else { k * w + line }; + dst[i] = acc / (2 * r + 1) as f32; + acc += at(k as i64 + r as i64 + 1) - at(k as i64 - r as i64); + } + } + dst + }; + pass(&pass(v, true), false) + } + + fn mix(mut z: u64) -> u64 { + z = z.wrapping_add(0x9E37_79B9_7F4A_7C15); + z = (z ^ (z >> 30)).wrapping_mul(0xBF58_476D_1CE4_E5B9); + z = (z ^ (z >> 27)).wrapping_mul(0x94D0_49BB_1331_11EB); + z ^ (z >> 31) + } + + /// Integer-only data with negatives, signed zeros and large values, so a different order of + /// additions cannot hide. + fn noise(len: usize, seed: u64) -> Vec { + (0..len as u64) + .map(|i| { + let z = mix(seed.wrapping_mul(0x1234_5678_9ABC).wrapping_add(i)); + let v = (z >> 40) as f32 / 16_777_216.0; + match z & 31 { + 0 => -v, + 1 => -0.0, + 2 => v * 1000.0, + _ => v, + } + }) + .collect() + } + + /// Blocks of random brightness: lots of strong corners, no libm in the making. + fn blocks(w: usize, h: usize, seed: u64) -> Vec { + (0..w * h).map(|i| (mix(seed + ((i % w) / 8) as u64 * 4096 + ((i / w) / 8) as u64) >> 40) as f32 / 16_777_216.0).collect() + } + + fn fnv(parts: &[u64]) -> u64 { + let mut h = 0xcbf2_9ce4_8422_2325u64; + for p in parts { + h ^= p; + h = h.wrapping_mul(0x0000_0100_0000_01b3); + } + h + } + + #[test] + fn blur_matches_the_serial_code() { + // Widths around the 64-column band (1 band, band + 1, several bands, a ragged last one), + // 1-wide and 1-high images, and radii 0, 1, 2, larger than the image. + let widths = [1usize, 2, 3, 31, 63, 64, 65, 127, 128, 129, 200]; + let heights = [1usize, 2, 3, 5, 17, 40]; + for (a, &w) in widths.iter().enumerate() { + for (b, &h) in heights.iter().enumerate() { + let v = noise(w * h, (a * 10 + b) as u64); + for r in [0usize, 1, 2, 3, 7, 50] { + let (got, want) = (blur(w, h, &v, r), ref_blur(w, h, &v, r)); + let (g, e): (Vec, Vec) = (got.iter().map(|x| x.to_bits()).collect(), want.iter().map(|x| x.to_bits()).collect()); + assert_eq!(g, e, "blur {w}x{h} r={r}"); + } + } + } + assert!(blur(0, 0, &[], 1).is_empty()); + } + + // `harris` and `describe` go through the platform's hypot / ln / cos / atan2 / sin_cos, so the + // hashes recorded from the old code only hold where those are the same functions. + #[cfg(all(target_os = "linux", target_arch = "x86_64"))] + #[test] + fn harris_and_describe_hashes_recorded_from_the_serial_code() { + // Sizes: a whole number of 64-column bands and a ragged one; with and without a validity mask. + for (w, h, max, md, want_corners, want_desc) in + [(211usize, 157usize, 300usize, 6.0f32, 0xeccc6928d3ae3b8cu64, 0xd1e6caae4d68f253u64), (128, 97, 60, 3.0, 0xe3f6615117fa7da7, 0x818ae7e965ecf88f)] + { + let img = blocks(w, h, 7); + let valid: Vec = (0..w * h).map(|i| !((i % w) > w / 2 && (i / w) < h / 3)).collect(); + let mut parts = Vec::new(); + for v in [None, Some(valid.as_slice())] { + let c = harris(w, h, &img, max, md, v); + for k in &c { + parts.extend([k.x.to_bits() as u64, k.y.to_bits() as u64, k.score.to_bits() as u64]); + } + parts.push(c.len() as u64); + } + assert_eq!(fnv(&parts), want_corners, "harris {w}x{h}"); + let c = harris(w, h, &img, max, md, None); + assert!(c.len() > 10, "{} corners", c.len()); + let mut parts = Vec::new(); + for upright in [false, true] { + for d in describe(w, h, &img, &c, upright) { + parts.extend(d); + } + } + assert_eq!(fnv(&parts), want_desc, "describe {w}x{h}"); + } + } +} diff --git a/crates/algo/src/panorama.rs b/crates/algo/src/panorama.rs index fd7ccbc591..abe17db395 100644 --- a/crates/algo/src/panorama.rs +++ b/crates/algo/src/panorama.rs @@ -35,7 +35,7 @@ #![allow(clippy::needless_range_loop)] // index loops mirror the maths (pixels × channels) use crate::features::{self, Descriptor, Model}; -use crate::photo_util::{Normal, Rng, par_map, solve_dense}; +use crate::photo_util::{Normal, Rng, par_map, par_rows, solve_dense}; use crate::transform::Homography; /// Photomerge layouts. @@ -996,58 +996,99 @@ pub fn blend_levels(w: usize, h: usize) -> usize { n } +/// `a[i] ⊕ b[i]` for every `i` (the shorter length wins, like `zip`), in parallel on native +/// targets. Purely elementwise, so the result is the serial result bit for bit. +fn par_zip(a: &[f32], b: &[f32], f: impl Fn(f32, f32) -> f32 + Sync + Send) -> Vec { + const CHUNK: usize = 1 << 14; + let mut out = vec![0.0f32; a.len().min(b.len())]; + let fill = |o: &mut [f32], a: &[f32], b: &[f32]| { + for ((o, &a), &b) in o.iter_mut().zip(a).zip(b) { + *o = f(a, b); + } + }; + #[cfg(not(target_arch = "wasm32"))] + { + use rayon::prelude::*; + out.par_chunks_mut(CHUNK).zip(a.par_chunks(CHUNK)).zip(b.par_chunks(CHUNK)).for_each(|((o, a), b)| fill(o, a, b)); + } + #[cfg(target_arch = "wasm32")] + out.chunks_mut(CHUNK).zip(a.chunks(CHUNK)).zip(b.chunks(CHUNK)).for_each(|((o, a), b)| fill(o, a, b)); + out +} + +/// `f(a[i])` for every `i`, in parallel on native targets (elementwise, so bit for bit serial). +fn par_map1(a: &[f32], f: impl Fn(f32) -> f32 + Sync + Send) -> Vec { + par_zip(a, a, |x, _| f(x)) +} + +// `reduce`, `expand` and `push_pull` run one pyramid level for one image at a time, and the +// images themselves only fill part of a many-core machine, so each of them also runs its rows +// in parallel. Every output element is computed by the same expression, in the same order, as +// the one-thread loops they replace (rows never read each other's output), so the result is +// bit for bit unchanged. + fn reduce(w: usize, h: usize, ch: usize, src: &[f32]) -> (usize, usize, Vec) { let (nw, nh) = (w.div_ceil(2), h.div_ceil(2)); let k = [1.0f32, 4.0, 6.0, 4.0, 1.0]; // Separable 5-tap binomial, then decimate. let mut tmp = vec![0.0f32; nw * h * ch]; - for y in 0..h { + par_rows(&mut tmp, nw, ch, |y, row| { + let line = &src[y * w * ch..(y + 1) * w * ch]; for x in 0..nw { + // The clamped source column of each tap is the same for every channel. + let xs: [usize; 5] = std::array::from_fn(|t| (2 * x as i64 + t as i64 - 2).clamp(0, w as i64 - 1) as usize * ch); for c in 0..ch { let mut s = 0.0; - for (t, kv) in k.iter().enumerate() { - let xx = (2 * x as i64 + t as i64 - 2).clamp(0, w as i64 - 1) as usize; - s += kv * src[(y * w + xx) * ch + c]; + for (kv, xx) in k.iter().zip(xs) { + s += kv * line[xx + c]; } - tmp[(y * nw + x) * ch + c] = s / 16.0; + row[x * ch + c] = s / 16.0; } } - } + }); let mut out = vec![0.0f32; nw * nh * ch]; - for y in 0..nh { - for x in 0..nw { - for c in 0..ch { - let mut s = 0.0; - for (t, kv) in k.iter().enumerate() { - let yy = (2 * y as i64 + t as i64 - 2).clamp(0, h as i64 - 1) as usize; - s += kv * tmp[(yy * nw + x) * ch + c]; - } - out[(y * nw + x) * ch + c] = s / 16.0; + par_rows(&mut out, nw, ch, |y, row| { + // Each output row is five source rows added in tap order: the same sum per element as + // before (`0 + k0·a + k1·b + …`), but over whole rows so it vectorises. + for (t, kv) in k.iter().enumerate() { + let yy = (2 * y as i64 + t as i64 - 2).clamp(0, h as i64 - 1) as usize; + for (o, &v) in row.iter_mut().zip(&tmp[yy * nw * ch..(yy + 1) * nw * ch]) { + *o += kv * v; } } - } + for o in row.iter_mut() { + *o /= 16.0; + } + }); (nw, nh, out) } fn expand(sw: usize, sh: usize, ch: usize, src: &[f32], w: usize, h: usize) -> Vec { // Bilinear upsampling at half-pixel-aligned positions. let mut out = vec![0.0f32; w * h * ch]; - for y in 0..h { - let fy = ((y as f32 + 0.5) / 2.0 - 0.5).clamp(0.0, (sh - 1) as f32); - let (y0, ty) = (fy.floor() as usize, fy - fy.floor()); - let y1 = (y0 + 1).min(sh - 1); - for x in 0..w { + // Source columns and weights depend on x only: worked out once instead of for every row. + // The clamped position is never negative, so truncating is `floor` (and spares the libm call). + let cols: Vec<(usize, usize, f32)> = (0..w) + .map(|x| { let fx = ((x as f32 + 0.5) / 2.0 - 0.5).clamp(0.0, (sw - 1) as f32); - let (x0, tx) = (fx.floor() as usize, fx - fx.floor()); - let x1 = (x0 + 1).min(sw - 1); - for c in 0..ch { - let p = |xx: usize, yy: usize| src[(yy * sw + xx) * ch + c]; - let top = p(x0, y0) + (p(x1, y0) - p(x0, y0)) * tx; - let bot = p(x0, y1) + (p(x1, y1) - p(x0, y1)) * tx; - out[(y * w + x) * ch + c] = top + (bot - top) * ty; + let x0 = fx as usize; + (x0 * ch, (x0 + 1).min(sw - 1) * ch, fx - x0 as f32) + }) + .collect(); + par_rows(&mut out, w, ch, |y, row| { + let fy = ((y as f32 + 0.5) / 2.0 - 0.5).clamp(0.0, (sh - 1) as f32); + let y0 = fy as usize; + let ty = fy - y0 as f32; + let y1 = (y0 + 1).min(sh - 1); + let (r0, r1) = (&src[y0 * sw * ch..(y0 + 1) * sw * ch], &src[y1 * sw * ch..(y1 + 1) * sw * ch]); + for (px, &(x0, x1, tx)) in row.chunks_exact_mut(ch.max(1)).zip(&cols) { + for (c, o) in px.iter_mut().enumerate() { + let top = r0[x0 + c] + (r0[x1 + c] - r0[x0 + c]) * tx; + let bot = r1[x0 + c] + (r1[x1 + c] - r1[x0 + c]) * tx; + *o = top + (bot - top) * ty; } } - } + }); out } @@ -1058,41 +1099,75 @@ fn push_pull(w: usize, h: usize, ch: usize, px: &mut [f32], wt: &[f32]) { return; } let (nw, nh) = (w.div_ceil(2), h.div_ceil(2)); + // Coarse sums. A coarse row is fed by two fine rows; walking them in raster order adds each + // coarse cell's (up to) four fine pixels in the order the serial scatter-add did, from zero. let mut cpx = vec![0.0f32; nw * nh * ch]; let mut cw = vec![0.0f32; nw * nh]; - for y in 0..h { - for x in 0..w { - let (i, j) = (y * w + x, (y / 2) * nw + x / 2); - let a = wt[i]; - cw[j] += a; - for c in 0..ch { - cpx[j * ch + c] += a * px[i * ch + c]; + par_rows(&mut cw, nw, 1, |cy, row| { + for y in 2 * cy..(2 * cy + 2).min(h) { + for (x, &a) in wt[y * w..(y + 1) * w].iter().enumerate() { + row[x / 2] += a; } } - } - for j in 0..nw * nh { - if cw[j] > 0.0 { - for c in 0..ch { - cpx[j * ch + c] /= cw[j]; + }); + let px_in: &[f32] = px; + par_rows(&mut cpx, nw, ch, |cy, row| { + for y in 2 * cy..(2 * cy + 2).min(h) { + for x in 0..w { + let (i, j) = (y * w + x, x / 2); + let a = wt[i]; + for c in 0..ch { + row[j * ch + c] += a * px_in[i * ch + c]; + } } - cw[j] = 1.0; + } + }); + // Normalise by the weight (this reads `cw` before it is reset to 0 / 1 below). + par_rows(&mut cpx, nw, ch, |cy, row| { + for cx in 0..nw { + let wj = cw[cy * nw + cx]; + if wj > 0.0 { + for c in 0..ch { + row[cx * ch + c] /= wj; + } + } + } + }); + for v in cw.iter_mut() { + if *v > 0.0 { + *v = 1.0; } } if cw.contains(&0.0) && cw.iter().any(|v| *v > 0.0) { push_pull(nw, nh, ch, &mut cpx, &cw); } - for y in 0..h { + par_rows(px, w, ch, |y, row| { for x in 0..w { let i = y * w + x; if wt[i] < 1.0 { let j = (y / 2) * nw + x / 2; let a = wt[i]; for c in 0..ch { - px[i * ch + c] = a * px[i * ch + c] + (1.0 - a) * cpx[j * ch + c]; + row[x * ch + c] = a * row[x * ch + c] + (1.0 - a) * cpx[j * ch + c]; } } } - } + }); +} + +/// One image's pyramid in [`multiband`]: the grid origin `(gx0, gy0)` at level 0, the Laplacian +/// levels `(w, h, colours)` and the Gaussian weight levels. +type PyramidBand = (i32, i32, Vec<(usize, usize, Vec)>, Vec>); + +/// The part of `band` on panorama row `py` of level `l`: the panorama column its first pixel +/// lands on, its width, its weight row and its colour row. `None` when it misses the row. +fn band_row(band: &PyramidBand, l: usize, ch: usize, py: usize) -> Option<(usize, usize, &[f32], &[f32])> { + let (ox, oy) = ((band.0 >> l) as usize, (band.1 >> l) as usize); + let (lw, lh, colours) = &band.2[l]; + (py >= oy && py - oy < *lh).then(|| { + let y = py - oy; + (ox, *lw, &band.3[l][y * lw..(y + 1) * lw], &colours[y * lw * ch..(y + 1) * lw * ch]) + }) } /// Multi-band blend of `imgs` into a `w × h` panorama (origin 0,0) with one weight map per image @@ -1119,22 +1194,37 @@ pub fn multiband(w: usize, h: usize, imgs: &[RoiImage], weights: &[Vec], le let gx1 = (im.x0 + im.w as i32 + margin).min(w as i32); let gy1 = (im.y0 + im.h as i32 + margin).min(h as i32); let (gw, gh) = ((gx1 - gx0).max(1) as usize, (gy1 - gy0).max(1) as usize); + // Each grid pixel takes the image pixel that lands on it (every image pixel lands on at + // most one), so rows of the grid are independent and fill in parallel. + let src_of = |gx: usize, gy: usize| -> Option { + let (x, y) = (gx as i64 + gx0 as i64 - im.x0 as i64, gy as i64 + gy0 as i64 - im.y0 as i64); + (x >= 0 && y >= 0 && (x as usize) < im.w && (y as usize) < im.h).then(|| y as usize * im.w + x as usize) + }; let mut px = vec![0.0f32; gw * gh * ch]; - let mut cov = vec![0.0f32; gw * gh]; - let mut wt = vec![0.0f32; gw * gh]; - for y in 0..im.h { - for x in 0..im.w { - let (gx, gy) = (im.x0 + x as i32 - gx0, im.y0 + y as i32 - gy0); - if gx < 0 || gy < 0 || gx as usize >= gw || gy as usize >= gh { - continue; + par_rows(&mut px, gw, ch, |gy, row| { + for gx in 0..gw { + if let Some(i) = src_of(gx, gy) { + row[gx * ch..(gx + 1) * ch].copy_from_slice(&im.px[i * ch..(i + 1) * ch]); } - let (i, g) = (y * im.w + x, gy as usize * gw + gx as usize); - cov[g] = im.alpha[i]; - wt[g] = weights[k][i] * im.alpha[i].min(1.0); - px[g * ch..(g + 1) * ch].copy_from_slice(&im.px[i * ch..(i + 1) * ch]); } - } - let bin: Vec = cov.iter().map(|a| if *a > 0.5 { 1.0 } else { 0.0 }).collect(); + }); + let mut cov = vec![0.0f32; gw * gh]; + par_rows(&mut cov, gw, 1, |gy, row| { + for (gx, o) in row.iter_mut().enumerate() { + if let Some(i) = src_of(gx, gy) { + *o = im.alpha[i]; + } + } + }); + let mut wt = vec![0.0f32; gw * gh]; + par_rows(&mut wt, gw, 1, |gy, row| { + for (gx, o) in row.iter_mut().enumerate() { + if let Some(i) = src_of(gx, gy) { + *o = weights[k][i] * im.alpha[i].min(1.0); + } + } + }); + let bin: Vec = par_map1(&cov, |a| if a > 0.5 { 1.0 } else { 0.0 }); push_pull(gw, gh, ch, &mut px, &bin); // Laplacian of the colours, Gaussian of the weights. let mut lap: Vec<(usize, usize, Vec)> = Vec::new(); @@ -1148,7 +1238,7 @@ pub fn multiband(w: usize, h: usize, imgs: &[RoiImage], weights: &[Vec], le } let (nw, nh, down) = reduce(cw, chh, ch, &cur); let up = expand(nw, nh, ch, &down, cw, chh); - lap.push((cw, chh, cur.iter().zip(&up).map(|(a, b)| a - b).collect())); + lap.push((cw, chh, par_zip(&cur, &up, |a, b| a - b))); gau.push(cwt.clone()); let (_, _, wdown) = reduce(cw, chh, 1, &cwt); cw = nw; @@ -1158,41 +1248,57 @@ pub fn multiband(w: usize, h: usize, imgs: &[RoiImage], weights: &[Vec], le } (gx0, gy0, lap, gau) }); - for (gx0, gy0, lap, gau) in &bands { - for (l, ((lw, lh, band), g)) in lap.iter().zip(gau).enumerate() { - let (ox, oy) = ((gx0 >> l) as usize, (gy0 >> l) as usize); - let (pw, ph) = sizes[l]; - for y in 0..*lh { - let py = oy + y; - if py >= ph { - break; - } - for x in 0..*lw { + // Accumulate the bands. Each pixel adds the bands in the same order as the serial loop (per + // level, bands one after another), but rows of a level are independent, so they run in + // parallel: every row walks the bands itself. + for l in 0..levels { + let pw = sizes[l].0; + par_rows(&mut wsum[l], pw, 1, |py, row| { + for b in &bands { + let Some((ox, lw, g, _)) = band_row(b, l, ch, py) else { continue }; + for x in 0..lw { let px = ox + x; if px >= pw { break; } - let (i, p) = (y * lw + x, py * pw + px); - let k = g[i]; + let k = g[x]; + if k <= 0.0 { + continue; + } + row[px] += k; + } + } + }); + par_rows(&mut acc[l], pw, ch, |py, row| { + for b in &bands { + let Some((ox, lw, g, band)) = band_row(b, l, ch, py) else { continue }; + for x in 0..lw { + let px = ox + x; + if px >= pw { + break; + } + let k = g[x]; if k <= 0.0 { continue; } - wsum[l][p] += k; for c in 0..ch { - acc[l][p * ch + c] += k * band[i * ch + c]; + row[px * ch + c] += k * band[x * ch + c]; } } } - } + }); } for l in 0..levels { - let (pw, ph) = sizes[l]; - for p in 0..pw * ph { - let k = wsum[l][p]; - for c in 0..ch { - acc[l][p * ch + c] = if k > 1e-8 { acc[l][p * ch + c] / k } else { 0.0 }; + let pw = sizes[l].0; + let ws = &wsum[l]; + par_rows(&mut acc[l], pw, ch, |py, row| { + for x in 0..pw { + let k = ws[py * pw + x]; + for c in 0..ch { + row[x * ch + c] = if k > 1e-8 { row[x * ch + c] / k } else { 0.0 }; + } } - } + }); } // Collapse. // `levels ≥ 1`, so there is always a coarsest level. @@ -1201,22 +1307,27 @@ pub fn multiband(w: usize, h: usize, imgs: &[RoiImage], weights: &[Vec], le let (pw, ph) = sizes[l]; let (sw, sh) = sizes[l + 1]; let up = expand(sw, sh, ch, &cur, pw, ph); - cur = acc[l].iter().zip(&up).map(|(a, b)| a + b).collect(); + cur = par_zip(&acc[l], &up, |a, b| a + b); } - // Coverage: union of the images. + // Coverage: union of the images. Rows are independent; each walks the images in order, so a + // pixel sees the same sequence of `max` calls as in the serial loop. let mut cov = vec![0.0f32; w * h]; - for im in imgs { - for y in 0..im.h { + par_rows(&mut cov, w, 1, |py, row| { + for im in imgs { + let y = py as i64 - im.y0 as i64; + if y < 0 || y as usize >= im.h { + continue; + } for x in 0..im.w { - let (px, py) = (im.x0 + x as i32, im.y0 + y as i32); - if px < 0 || py < 0 || px as usize >= w || py as usize >= h { + let px = im.x0 + x as i32; + if px < 0 || px as usize >= w { continue; } - let p = py as usize * w + px as usize; - cov[p] = cov[p].max(im.alpha[y * im.w + x]); + let p = px as usize; + row[p] = row[p].max(im.alpha[y as usize * im.w + x]); } } - } + }); (cur, cov) } @@ -1431,3 +1542,389 @@ mod tests { assert!(cov.iter().all(|c| *c == 1.0)); } } + +/// The row-parallel `reduce` / `expand` / `push_pull` / `multiband` against the serial code they +/// replaced, kept below verbatim as the reference. Every output must stay bit for bit the same. +#[cfg(test)] +mod parallel_tests { + use super::*; + + fn mix(mut z: u64) -> u64 { + z = z.wrapping_add(0x9E37_79B9_7F4A_7C15); + z = (z ^ (z >> 30)).wrapping_mul(0xBF58_476D_1CE4_E5B9); + z = (z ^ (z >> 27)).wrapping_mul(0x94D0_49BB_1331_11EB); + z ^ (z >> 31) + } + + /// Deterministic data from integer arithmetic only (no libm): mostly `0..1`, with negatives, + /// signed zeros and large values so a different order of operations cannot hide. + fn noise(len: usize, seed: u64) -> Vec { + (0..len as u64) + .map(|i| { + let z = mix(seed.wrapping_mul(0x1234_5678_9ABC).wrapping_add(i)); + let v = (z >> 40) as f32 / 16_777_216.0; + match z & 31 { + 0 => -v, + 1 => -0.0, + 2 => v * 1000.0, + 3 => 0.0, + _ => v, + } + }) + .collect() + } + + /// Weights with exact zeros, exact ones and partial values, in blobs and noise. + fn weights(w: usize, h: usize, seed: u64) -> Vec { + (0..w * h) + .map(|i| { + let (x, y) = (i % w, i / w); + let z = mix(seed + i as u64); + if (x / 5 + y / 3) % 3 == 0 { + 0.0 + } else if z.is_multiple_of(7) { + ((z >> 20) % 100) as f32 / 100.0 + } else { + 1.0 + } + }) + .collect() + } + + fn bits(v: &[f32]) -> Vec { + v.iter().map(|x| x.to_bits()).collect() + } + + fn fnv(parts: &[&[f32]]) -> u64 { + let mut h = 0xcbf2_9ce4_8422_2325u64; + for p in parts { + for x in *p { + h ^= x.to_bits() as u64; + h = h.wrapping_mul(0x0000_0100_0000_01b3); + } + } + h + } + + /// Sizes that cross the odd / even and tiny cases (1 wide, 1 high, 1×1, odd, power of two + 1). + const SIZES: [(usize, usize); 12] = [(1, 1), (1, 9), (9, 1), (2, 2), (3, 5), (4, 4), (17, 13), (64, 40), (65, 41), (97, 61), (130, 7), (7, 130)]; + + fn ref_reduce(w: usize, h: usize, ch: usize, src: &[f32]) -> (usize, usize, Vec) { + let (nw, nh) = (w.div_ceil(2), h.div_ceil(2)); + let k = [1.0f32, 4.0, 6.0, 4.0, 1.0]; + // Separable 5-tap binomial, then decimate. + let mut tmp = vec![0.0f32; nw * h * ch]; + for y in 0..h { + for x in 0..nw { + for c in 0..ch { + let mut s = 0.0; + for (t, kv) in k.iter().enumerate() { + let xx = (2 * x as i64 + t as i64 - 2).clamp(0, w as i64 - 1) as usize; + s += kv * src[(y * w + xx) * ch + c]; + } + tmp[(y * nw + x) * ch + c] = s / 16.0; + } + } + } + let mut out = vec![0.0f32; nw * nh * ch]; + for y in 0..nh { + for x in 0..nw { + for c in 0..ch { + let mut s = 0.0; + for (t, kv) in k.iter().enumerate() { + let yy = (2 * y as i64 + t as i64 - 2).clamp(0, h as i64 - 1) as usize; + s += kv * tmp[(yy * nw + x) * ch + c]; + } + out[(y * nw + x) * ch + c] = s / 16.0; + } + } + } + (nw, nh, out) + } + + fn ref_expand(sw: usize, sh: usize, ch: usize, src: &[f32], w: usize, h: usize) -> Vec { + // Bilinear upsampling at half-pixel-aligned positions. + let mut out = vec![0.0f32; w * h * ch]; + for y in 0..h { + let fy = ((y as f32 + 0.5) / 2.0 - 0.5).clamp(0.0, (sh - 1) as f32); + let (y0, ty) = (fy.floor() as usize, fy - fy.floor()); + let y1 = (y0 + 1).min(sh - 1); + for x in 0..w { + let fx = ((x as f32 + 0.5) / 2.0 - 0.5).clamp(0.0, (sw - 1) as f32); + let (x0, tx) = (fx.floor() as usize, fx - fx.floor()); + let x1 = (x0 + 1).min(sw - 1); + for c in 0..ch { + let p = |xx: usize, yy: usize| src[(yy * sw + xx) * ch + c]; + let top = p(x0, y0) + (p(x1, y0) - p(x0, y0)) * tx; + let bot = p(x0, y1) + (p(x1, y1) - p(x0, y1)) * tx; + out[(y * w + x) * ch + c] = top + (bot - top) * ty; + } + } + } + out + } + + fn ref_push_pull(w: usize, h: usize, ch: usize, px: &mut [f32], wt: &[f32]) { + if w <= 1 && h <= 1 { + return; + } + let (nw, nh) = (w.div_ceil(2), h.div_ceil(2)); + let mut cpx = vec![0.0f32; nw * nh * ch]; + let mut cw = vec![0.0f32; nw * nh]; + for y in 0..h { + for x in 0..w { + let (i, j) = (y * w + x, (y / 2) * nw + x / 2); + let a = wt[i]; + cw[j] += a; + for c in 0..ch { + cpx[j * ch + c] += a * px[i * ch + c]; + } + } + } + for j in 0..nw * nh { + if cw[j] > 0.0 { + for c in 0..ch { + cpx[j * ch + c] /= cw[j]; + } + cw[j] = 1.0; + } + } + if cw.contains(&0.0) && cw.iter().any(|v| *v > 0.0) { + ref_push_pull(nw, nh, ch, &mut cpx, &cw); + } + for y in 0..h { + for x in 0..w { + let i = y * w + x; + if wt[i] < 1.0 { + let j = (y / 2) * nw + x / 2; + let a = wt[i]; + for c in 0..ch { + px[i * ch + c] = a * px[i * ch + c] + (1.0 - a) * cpx[j * ch + c]; + } + } + } + } + } + + fn ref_multiband(w: usize, h: usize, imgs: &[RoiImage], weights: &[Vec], levels: usize) -> (Vec, Vec) { + let ch = imgs.first().map_or(1, |i| i.ch); + let levels = levels.max(1); + let align = 1i32 << (levels - 1); + // Level sizes of the panorama. + let mut sizes = vec![(w, h)]; + for _ in 1..levels { + let Some(&(pw, ph)) = sizes.last() else { break }; + sizes.push((pw.div_ceil(2), ph.div_ceil(2))); + } + let mut acc: Vec> = sizes.iter().map(|&(lw, lh)| vec![0.0; lw * lh * ch]).collect(); + let mut wsum: Vec> = sizes.iter().map(|&(lw, lh)| vec![0.0; lw * lh]).collect(); + let margin = align * 4; + // Each image on its ROI grown by a margin and aligned to the coarsest level's grid. + let bands = par_map(imgs.len(), |k| { + let im = &imgs[k]; + let gx0 = ((im.x0 - margin).max(0) / align) * align; + let gy0 = ((im.y0 - margin).max(0) / align) * align; + let gx1 = (im.x0 + im.w as i32 + margin).min(w as i32); + let gy1 = (im.y0 + im.h as i32 + margin).min(h as i32); + let (gw, gh) = ((gx1 - gx0).max(1) as usize, (gy1 - gy0).max(1) as usize); + let mut px = vec![0.0f32; gw * gh * ch]; + let mut cov = vec![0.0f32; gw * gh]; + let mut wt = vec![0.0f32; gw * gh]; + for y in 0..im.h { + for x in 0..im.w { + let (gx, gy) = (im.x0 + x as i32 - gx0, im.y0 + y as i32 - gy0); + if gx < 0 || gy < 0 || gx as usize >= gw || gy as usize >= gh { + continue; + } + let (i, g) = (y * im.w + x, gy as usize * gw + gx as usize); + cov[g] = im.alpha[i]; + wt[g] = weights[k][i] * im.alpha[i].min(1.0); + px[g * ch..(g + 1) * ch].copy_from_slice(&im.px[i * ch..(i + 1) * ch]); + } + } + let bin: Vec = cov.iter().map(|a| if *a > 0.5 { 1.0 } else { 0.0 }).collect(); + ref_push_pull(gw, gh, ch, &mut px, &bin); + // Laplacian of the colours, Gaussian of the weights. + let mut lap: Vec<(usize, usize, Vec)> = Vec::new(); + let mut gau: Vec> = Vec::new(); + let (mut cw, mut chh, mut cur, mut cwt) = (gw, gh, px, wt); + for l in 0..levels { + if l + 1 == levels { + lap.push((cw, chh, cur.clone())); + gau.push(cwt.clone()); + break; + } + let (nw, nh, down) = ref_reduce(cw, chh, ch, &cur); + let up = ref_expand(nw, nh, ch, &down, cw, chh); + lap.push((cw, chh, cur.iter().zip(&up).map(|(a, b)| a - b).collect())); + gau.push(cwt.clone()); + let (_, _, wdown) = ref_reduce(cw, chh, 1, &cwt); + cw = nw; + chh = nh; + cur = down; + cwt = wdown; + } + (gx0, gy0, lap, gau) + }); + for (gx0, gy0, lap, gau) in &bands { + for (l, ((lw, lh, band), g)) in lap.iter().zip(gau).enumerate() { + let (ox, oy) = ((gx0 >> l) as usize, (gy0 >> l) as usize); + let (pw, ph) = sizes[l]; + for y in 0..*lh { + let py = oy + y; + if py >= ph { + break; + } + for x in 0..*lw { + let px = ox + x; + if px >= pw { + break; + } + let (i, p) = (y * lw + x, py * pw + px); + let k = g[i]; + if k <= 0.0 { + continue; + } + wsum[l][p] += k; + for c in 0..ch { + acc[l][p * ch + c] += k * band[i * ch + c]; + } + } + } + } + } + for l in 0..levels { + let (pw, ph) = sizes[l]; + for p in 0..pw * ph { + let k = wsum[l][p]; + for c in 0..ch { + acc[l][p * ch + c] = if k > 1e-8 { acc[l][p * ch + c] / k } else { 0.0 }; + } + } + } + // Collapse. + // `levels ≥ 1`, so there is always a coarsest level. + let Some(mut cur) = acc.pop() else { return (vec![0.0; w * h * ch], vec![0.0; w * h]) }; + for l in (0..levels - 1).rev() { + let (pw, ph) = sizes[l]; + let (sw, sh) = sizes[l + 1]; + let up = ref_expand(sw, sh, ch, &cur, pw, ph); + cur = acc[l].iter().zip(&up).map(|(a, b)| a + b).collect(); + } + // Coverage: union of the images. + let mut cov = vec![0.0f32; w * h]; + for im in imgs { + for y in 0..im.h { + for x in 0..im.w { + let (px, py) = (im.x0 + x as i32, im.y0 + y as i32); + if px < 0 || py < 0 || px as usize >= w || py as usize >= h { + continue; + } + let p = py as usize * w + px as usize; + cov[p] = cov[p].max(im.alpha[y * im.w + x]); + } + } + } + (cur, cov) + } + + #[test] + fn reduce_and_expand_match_the_serial_code() { + for (n, &(w, h)) in SIZES.iter().enumerate() { + for ch in [1usize, 3, 4] { + let src = noise(w * h * ch, n as u64 * 7 + ch as u64); + let (nw, nh, got) = reduce(w, h, ch, &src); + let (rw, rh, want) = ref_reduce(w, h, ch, &src); + assert_eq!((nw, nh), (rw, rh)); + assert_eq!(bits(&got), bits(&want), "reduce {w}x{h}x{ch}"); + // Expand back to the original size and to sizes that are not twice the source's. + for (ew, eh) in [(w, h), (2 * nw, 2 * nh), (2 * nw - 1, 2 * nh + 1), (w + 3, h + 5)] { + let got = expand(nw, nh, ch, &want, ew, eh); + let want_up = ref_expand(nw, nh, ch, &want, ew, eh); + assert_eq!(bits(&got), bits(&want_up), "expand {nw}x{nh}x{ch} to {ew}x{eh}"); + } + } + } + } + + #[test] + fn push_pull_matches_the_serial_code() { + for (n, &(w, h)) in SIZES.iter().enumerate() { + for ch in [1usize, 3] { + for variant in 0..3u64 { + let wt = match variant { + 0 => weights(w, h, n as u64), + 1 => vec![1.0; w * h], + // A single covered pixel: every level of the recursion has to fill from it. + _ => (0..w * h).map(|i| if i == (w * h) / 3 { 1.0 } else { 0.0 }).collect(), + }; + let px = noise(w * h * ch, 100 + n as u64); + let (mut got, mut want) = (px.clone(), px); + push_pull(w, h, ch, &mut got, &wt); + ref_push_pull(w, h, ch, &mut want, &wt); + assert_eq!(bits(&got), bits(&want), "push_pull {w}x{h}x{ch} variant {variant}"); + } + } + } + } + + /// ROIs of assorted sizes and offsets (some sticking out of the canvas), with holes in the alpha. + fn rois(w: usize, h: usize, ch: usize, seed: u64) -> (Vec, Vec>) { + let specs = + [(0i32, 0i32, w * 2 / 3, h), (w as i32 / 3, -(h as i32) / 5, w * 2 / 3 + 1, h * 3 / 4 + 1), (-(w as i32) / 4, h as i32 / 2, w / 2 + 1, h / 2 + 3)]; + let imgs: Vec = specs + .iter() + .enumerate() + .map(|(k, &(x0, y0, iw, ih))| { + let (iw, ih) = (iw.max(1), ih.max(1)); + let mut alpha = vec![1.0f32; iw * ih]; + for (i, a) in alpha.iter_mut().enumerate() { + let z = mix(seed + k as u64 * 1000 + i as u64); + if (i % iw) / 4 % 5 == 2 && (i / iw) % 6 < 3 { + *a = 0.0; + } else if z.is_multiple_of(11) { + *a = 0.3; + } + } + RoiImage { x0, y0, w: iw, h: ih, ch, px: noise(iw * ih * ch, seed + k as u64), alpha } + }) + .collect(); + let wts = imgs.iter().enumerate().map(|(k, r)| weights(r.w, r.h, seed + 50 + k as u64)).collect(); + (imgs, wts) + } + + #[test] + fn multiband_matches_the_serial_code() { + for (n, &(w, h)) in [(1usize, 1usize), (5, 3), (40, 30), (97, 61), (130, 70), (64, 64), (201, 129)].iter().enumerate() { + for ch in [1usize, 3] { + let (imgs, wts) = rois(w, h, ch, n as u64 * 31 + ch as u64); + for levels in [1usize, 2, 3, 5, 7] { + let got = multiband(w, h, &imgs, &wts, levels); + let want = ref_multiband(w, h, &imgs, &wts, levels); + assert_eq!(bits(&got.0), bits(&want.0), "colours {w}x{h}x{ch} levels {levels}"); + assert_eq!(bits(&got.1), bits(&want.1), "coverage {w}x{h}x{ch} levels {levels}"); + } + } + } + // No images at all, and zero levels. + assert_eq!(multiband(9, 7, &[], &[], 3).0.len(), ref_multiband(9, 7, &[], &[], 3).0.len()); + } + + /// Hashes recorded from the code before it was made parallel (plain f32 arithmetic only, so + /// they hold on every platform). + #[test] + fn multiband_hashes_recorded_from_the_serial_code() { + let (imgs, wts) = rois(97, 61, 3, 5); + let (px, cov) = multiband(97, 61, &imgs, &wts, 5); + assert_eq!(fnv(&[&px, &cov]), 0xf89a5726082ebb6c, "97x61 rgb, 5 levels"); + let (imgs, wts) = rois(130, 70, 1, 9); + let (px, cov) = multiband(130, 70, &imgs, &wts, 7); + assert_eq!(fnv(&[&px, &cov]), 0xb7d7ddc59d201730, "130x70 grey, 7 levels"); + let wt = weights(65, 41, 3); + let mut px = noise(65 * 41 * 3, 4); + push_pull(65, 41, 3, &mut px, &wt); + assert_eq!(fnv(&[&px]), 0x50ea36a84ca9a68f, "push_pull 65x41 rgb"); + let (nw, nh, r) = reduce(65, 41, 3, &px); + assert_eq!(fnv(&[&r, &expand(nw, nh, 3, &r, 65, 41)]), 0x51823acfb4206dd8, "reduce + expand 65x41 rgb"); + } +} From 351149f0df48d1a0c93ad56d0b0940930f50aba1 Mon Sep 17 00:00:00 2001 From: Joseph <56056764+00xJS@users.noreply.github.com> Date: Sat, 17 Oct 2026 07:32:54 -0700 Subject: [PATCH 72/85] Fix the build: restore export_presets in load_presets_json (#5344 x #5616) (#6906) #5344 added the export_presets field to Persisted; #5616 rewrote load_presets_json to read each set with take() and its Persisted initializer lacked the new field, so main stopped compiling (E0063 at presets/mod.rs:180). Read export_presets the same way as the other sets; the existing load step and round-trip test cover it. Co-authored-by: 00xJ0 <56056764+00xJ0@users.noreply.github.com> --- crates/engine/src/presets/mod.rs | 1 + 1 file changed, 1 insertion(+) diff --git a/crates/engine/src/presets/mod.rs b/crates/engine/src/presets/mod.rs index 03696291dd..81382b418a 100644 --- a/crates/engine/src/presets/mod.rs +++ b/crates/engine/src/presets/mod.rs @@ -186,6 +186,7 @@ impl Session { tool_presets: take(&mut m, "tool_presets"), documents: take(&mut m, "documents"), recent_documents: take(&mut m, "recent_documents"), + export_presets: take(&mut m, "export_presets"), custom_shapes: take(&mut m, "custom_shapes"), layer_defaults: take(&mut m, "layer_defaults"), swatches: take(&mut m, "swatches"), From efa1edd079b259b0b1f77b835151ee904437a231 Mon Sep 17 00:00:00 2001 From: Hamza Naveed <45464935+Hamza0101@users.noreply.github.com> Date: Sat, 17 Oct 2026 19:39:15 +0500 Subject: [PATCH 73/85] algo: gate the recorded-hash test's helpers with the test (#7053) `blocks` and `fnv` exist only for `harris_and_describe_hashes_recorded_from_the _serial_code`, which is `#[cfg(all(target_os = "linux", target_arch = "x86_64"))]` because the hashes it compares against go through the platform's hypot / ln / cos / atan2 / sin_cos. Everywhere else the test is compiled out and the two helpers are left unused, so `-D warnings` fails on `dead_code`: error: function `blocks` is never used crates/algo/src/features.rs:704 error: function `fnv` is never used crates/algo/src/features.rs:708 They now carry the same cfg as the only thing that calls them. CI's matrix is ubuntu-latest and windows-latest and runs clippy on both, so this fails the Windows leg as much as a local run on macOS: `target_os = "linux"` is false for both. --- crates/algo/src/features.rs | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/crates/algo/src/features.rs b/crates/algo/src/features.rs index e8e45bcaf2..ec74891074 100644 --- a/crates/algo/src/features.rs +++ b/crates/algo/src/features.rs @@ -700,11 +700,14 @@ mod parallel_tests { .collect() } - /// Blocks of random brightness: lots of strong corners, no libm in the making. + /// Blocks of random brightness: lots of strong corners, no libm in the making. Only the + /// recorded-hash test below uses it, and that one is Linux x86_64 only. + #[cfg(all(target_os = "linux", target_arch = "x86_64"))] fn blocks(w: usize, h: usize, seed: u64) -> Vec { (0..w * h).map(|i| (mix(seed + ((i % w) / 8) as u64 * 4096 + ((i / w) / 8) as u64) >> 40) as f32 / 16_777_216.0).collect() } + #[cfg(all(target_os = "linux", target_arch = "x86_64"))] fn fnv(parts: &[u64]) -> u64 { let mut h = 0xcbf2_9ce4_8422_2325u64; for p in parts { From 45afd2e9d7c8b6391a1a72e50b1e3bfb07e4e1ba Mon Sep 17 00:00:00 2001 From: Ivan Date: Sat, 17 Oct 2026 18:37:12 +0300 Subject: [PATCH 74/85] CI: run panic_hunt in the Linux test job The full-registry fuzz (AGENTS.md Rule 9) is #[ignore]d and no workflow ran it, so nothing enforced "must stay green". The new step reruns the Test step's nextest selection with --run-ignored only, filtered to the panic_hunt binary, so nothing rebuilds; the ci profile terminates it if it hangs. Co-Authored-By: Claude Opus 5.5 --- .github/workflows/ci.yml | 10 ++++++++++ 1 file changed, 10 insertions(+) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 4303f0aac4..ba8243353d 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -193,6 +193,16 @@ jobs: cargo nextest run --profile ci --workspace --locked --features heif "${targets[@]}" shell: bash + # Every command against adversarial params must return Err, never panic or hang (AGENTS.md + # Rule 9). It is `#[ignore]`d (slow), so Test skips it. Same packages, features and targets as + # Test, so nothing rebuilds: `--test panic_hunt` would drop the other crates' dev-dependency + # features and recompile the whole graph. The `ci` profile terminates it if it hangs. + - name: Panic hunt + if: runner.os == 'Linux' + run: >- + cargo nextest run --profile ci --workspace --locked --features heif --lib --bins --tests + --run-ignored only -E 'binary_id(photocraft-engine::panic_hunt)' + - name: Doctests if: matrix.part == 'all' || matrix.part == 'unit' run: cargo test --workspace --locked --features heif --doc From a4fe4ffbed79e6e4a0b15a7f7b24440e61bc2261 Mon Sep 17 00:00:00 2001 From: Ivan Date: Mon, 12 Oct 2026 12:17:23 +0000 Subject: [PATCH 75/85] Contributors: add 1Git2Clone Co-Authored-By: Claude Opus 5.5 --- contributors/people.toml | 3 +++ 1 file changed, 3 insertions(+) diff --git a/contributors/people.toml b/contributors/people.toml index 8694c72698..30e50bb007 100644 --- a/contributors/people.toml +++ b/contributors/people.toml @@ -162,3 +162,6 @@ sync_github_name = true [people.sidney] real_name = "Sidney Markowitz" sync_github_name = true + +[people.1Git2Clone] +sync_github_name = true From 1dcaa28b962f94356b945b2673c60bc1a934d5df Mon Sep 17 00:00:00 2001 From: Zuujis <160198409+zuujiscom@users.noreply.github.com> Date: Sat, 17 Oct 2026 12:53:10 -0500 Subject: [PATCH 76/85] Quick Export: Same Folder asks before replacing the document's own file (#6599) (#6632) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit With Export Preferences › Location = Same Folder, Quick Export wrote . beside the document's file with no check, so a document opened from photo.png was exported over photo.png itself. The app now opens the save dialog for that case, and file.export.quickExport refuses it without a `path`. Overwriting an earlier export beside a PSD is kept. Adds file_cmds::same_file (text, dev+inode on Unix, case-insensitive canonical path on Windows). Co-authored-by: Claude Opus 5.5 --- crates/engine/src/file_cmds.rs | 23 ++++++++++++ crates/engine/src/web_cmds.rs | 41 +++++++++++++++------ crates/engine/src/web_cmds/tests.rs | 48 +++++++++++++++++++++++++ crates/ui-egui/src/export_dialog.rs | 5 ++- crates/ui-egui/src/file_dialog/tests.rs | 31 ++++++++++++++++ 5 files changed, 137 insertions(+), 11 deletions(-) diff --git a/crates/engine/src/file_cmds.rs b/crates/engine/src/file_cmds.rs index 43828b76b1..bfaca603de 100644 --- a/crates/engine/src/file_cmds.rs +++ b/crates/engine/src/file_cmds.rs @@ -219,6 +219,29 @@ pub(crate) fn stem(path: &str) -> String { } } +/// Whether `a` and `b` name the same file: the same text, or, when both exist, the same file on +/// disk (a hard link, a different case on a case-insensitive volume, `./` or `..` in one of them). +pub fn same_file(a: &str, b: &str) -> bool { + if a == b { + return true; + } + #[cfg(unix)] + { + use std::os::unix::fs::MetadataExt; + if let (Ok(x), Ok(y)) = (std::fs::metadata(a), std::fs::metadata(b)) { + return x.dev() == y.dev() && x.ino() == y.ino(); + } + } + #[cfg(windows)] + { + // NTFS and FAT compare names without case. + if let (Ok(x), Ok(y)) = (std::fs::canonicalize(a), std::fs::canonicalize(b)) { + return x.to_string_lossy().to_lowercase() == y.to_string_lossy().to_lowercase(); + } + } + false +} + pub(crate) fn join(dir: &str, name: &str) -> String { if dir.is_empty() { name.to_string() diff --git a/crates/engine/src/web_cmds.rs b/crates/engine/src/web_cmds.rs index 595ad21956..25cd235b2c 100644 --- a/crates/engine/src/web_cmds.rs +++ b/crates/engine/src/web_cmds.rs @@ -1024,27 +1024,48 @@ fn export_preferences(s: &mut Session, p: &Value) -> Result { Ok(json!({"section": "export", "values": s.prefs().get("export").unwrap_or(Value::Null)})) } +/// The Quick Export format's extension (Export Preferences › Format). +fn quick_export_ext(s: &Session) -> String { + serde_json::to_value(s.prefs().export.quick_export_format).ok().and_then(|v| v.as_str().map(str::to_string)).unwrap_or_else(|| "png".into()) +} + +/// Where Quick Export writes the active document with no `path` given: `.` beside +/// its file, when Export Preferences › Location is Same Folder and the document has a file. +pub fn quick_export_target(s: &Session) -> Option { + let same = s.prefs().export.quick_export_location == crate::prefs::ExportLocation::SameFolder; + let dp = s.active()?.path.as_deref().filter(|_| same)?; + let dir = std::path::Path::new(dp).parent().map(|d| d.to_string_lossy().into_owned()).unwrap_or_default(); + Some(join(&dir, &format!("{}.{}", stem(dp), quick_export_ext(s)))) +} + +/// Whether [`quick_export_target`] is the active document's own file (a document opened from +/// `photo.png` and exported as PNG), which Quick Export must not replace without asking (#6599). +pub fn quick_export_target_is_own_file(s: &Session) -> bool { + let own = s.active().and_then(|d| d.path.as_deref()); + quick_export_target(s).zip(own).is_some_and(|(target, own)| crate::file_cmds::same_file(&target, own)) +} + /// File › Export › Quick Export as : the format, quality, metadata, colour space and /// location from Export Preferences. `path` overrides the location ("ask" needs one). fn quick_export(s: &mut Session, p: &Value) -> Result { let cmd = "file.export.quickExport"; let prefs = s.prefs().export.clone(); - let fmt = serde_json::to_value(prefs.quick_export_format).ok().and_then(|v| v.as_str().map(str::to_string)).unwrap_or_else(|| "png".into()); - let d = s.active().ok_or(EngineError::NoDocument)?; - let doc = d.doc.clone(); + let fmt = quick_export_ext(s); let path = match p.get("path").and_then(Value::as_str).filter(|v| !v.is_empty()) { Some(v) => v.to_string(), None => { - let same = serde_json::to_value(prefs.quick_export_location).ok().and_then(|v| v.as_str().map(str::to_string)).is_some_and(|v| v == "sameFolder"); - match (&d.path, same) { - (Some(dp), true) => { - let dir = std::path::Path::new(dp).parent().map(|d| d.to_string_lossy().into_owned()).unwrap_or_default(); - join(&dir, &format!("{}.{fmt}", stem(dp))) - } - _ => return Err(bad(cmd, "missing `path` (Export Preferences › Location is \"ask\", or the document was never saved)")), + let Some(target) = quick_export_target(s) else { + return Err(bad(cmd, "missing `path` (Export Preferences › Location is \"ask\", or the document was never saved)")); + }; + // The document's own file is never replaced by an export the user didn't name (#6599). + if quick_export_target_is_own_file(s) { + return Err(bad(cmd, format!("the export would replace the document's own file {target}; give a `path`"))); } + target } }; + let d = s.active().ok_or(EngineError::NoDocument)?; + let doc = d.doc.clone(); let metadata = serde_json::to_value(prefs.metadata).ok().and_then(|v| v.as_str().map(str::to_string)).unwrap_or_default(); let mut o = serde_json::Map::new(); o.insert( diff --git a/crates/engine/src/web_cmds/tests.rs b/crates/engine/src/web_cmds/tests.rs index c3ffe6edce..1b91dcf726 100644 --- a/crates/engine/src/web_cmds/tests.rs +++ b/crates/engine/src/web_cmds/tests.rs @@ -437,6 +437,54 @@ fn export_preferences_drive_quick_export() { s.execute("prefs.reset", json!({"path": "export"})).unwrap(); } +/// #6599: with Location = Same Folder, a document opened from `photo.png` would be exported over +/// `photo.png` itself. Without a `path` that is refused and the file is left alone; named +/// explicitly (the save dialog's answer) it is written. +#[test] +fn quick_export_never_replaces_the_documents_own_file_unasked() { + let dir = tmp("quick_own_file"); + let mut s = session(8); + s.execute("file.export.exportPreferences", json!({"quickExportLocation": "sameFolder", "quickExportFormat": "png"})).unwrap(); + let own = format!("{dir}/photo.png"); + std::fs::write(&own, b"original").unwrap(); + s.active_mut().unwrap().path = Some(own.clone()); + assert_eq!(quick_export_target(&s), Some(own.clone())); + assert!(quick_export_target_is_own_file(&s)); + let e = s.execute("file.export.quickExport", json!({})).unwrap_err().to_string(); + assert!(e.contains("own file"), "{e}"); + assert_eq!(std::fs::read(&own).unwrap(), b"original", "the source is untouched"); + s.execute("file.export.quickExport", json!({"path": own})).unwrap(); + assert_eq!(decode(&own).dimensions(), (64, 48)); + // Another format, or a PSD's earlier export beside it, is still written without asking. + s.execute("file.export.exportPreferences", json!({"quickExportFormat": "jpg"})).unwrap(); + assert!(!quick_export_target_is_own_file(&s)); + assert_eq!(s.execute("file.export.quickExport", json!({})).unwrap()["path"], json!(format!("{dir}/photo.jpg"))); + s.execute("file.export.exportPreferences", json!({"quickExportFormat": "png"})).unwrap(); + s.active_mut().unwrap().path = Some(format!("{dir}/photo.psd")); + assert!(!quick_export_target_is_own_file(&s)); + assert_eq!(s.execute("file.export.quickExport", json!({})).unwrap()["path"], json!(own)); + s.execute("prefs.reset", json!({"path": "export"})).unwrap(); +} + +#[test] +fn same_file_sees_through_other_names_for_one_file() { + let dir = tmp("same_file"); + let (a, b, c) = (format!("{dir}/a.png"), format!("{dir}/b.png"), format!("{dir}/c.png")); + std::fs::write(&a, b"a").unwrap(); + std::fs::write(&c, b"c").unwrap(); + assert!(crate::file_cmds::same_file(&a, &a)); + assert!(crate::file_cmds::same_file(&a, &format!("{dir}/./a.png"))); + assert!(!crate::file_cmds::same_file(&a, &c)); + assert!(!crate::file_cmds::same_file(&a, &b), "a missing file is not the same file"); + #[cfg(unix)] + { + std::fs::hard_link(&a, &b).unwrap(); + assert!(crate::file_cmds::same_file(&a, &b), "a hard link is the same file"); + } + #[cfg(windows)] + assert!(crate::file_cmds::same_file(&a, &format!("{dir}/A.PNG")), "NTFS names ignore case"); +} + #[test] fn webp_export_preferences_select_lossless_or_lossy_output() { let dir = tmp("quick_webp_quality"); diff --git a/crates/ui-egui/src/export_dialog.rs b/crates/ui-egui/src/export_dialog.rs index 9367045aff..275221e050 100644 --- a/crates/ui-egui/src/export_dialog.rs +++ b/crates/ui-egui/src/export_dialog.rs @@ -907,7 +907,10 @@ pub fn quick_export_png(app: &mut PhotocraftApp) -> Result { let fmt = serde_json::to_value(prefs.quick_export_format).ok().and_then(|v| v.as_str().map(str::to_string)).unwrap_or_else(|| "png".into()); let same = serde_json::to_value(prefs.quick_export_location).ok().is_some_and(|v| v == "sameFolder"); let saved = app.session.active().is_some_and(|d| d.path.is_some()); - if same && saved { + // Same Folder writes beside the document's file without asking, except when that would + // replace the file itself (a document opened from photo.png): then the save dialog asks, + // so replacing it is the user's choice (#6599). + if same && saved && !photocraft_engine::web_cmds::quick_export_target_is_own_file(&app.session) { return quick_export(app, json!({})); } let st = app.session.active().ok_or("no document")?; diff --git a/crates/ui-egui/src/file_dialog/tests.rs b/crates/ui-egui/src/file_dialog/tests.rs index 6a3ccfcf9e..bb827274ff 100644 --- a/crates/ui-egui/src/file_dialog/tests.rs +++ b/crates/ui-egui/src/file_dialog/tests.rs @@ -838,6 +838,37 @@ fn quick_export_selected_layers_writes_separate_pngs_with_one_prompt() { assert!(!app.ui.status_error, "{}", app.ui.status); } +/// #6599: Quick Export as PNG with Location = Same Folder asks before replacing the document's +/// own file (a document opened from photo.png), and keeps writing beside a PSD without a prompt. +#[test] +fn same_folder_quick_export_asks_before_replacing_the_documents_own_file() { + let dir = std::env::temp_dir().join(format!("photocraft-quick-own-{}", std::process::id())); + let _ = std::fs::remove_dir_all(&dir); + std::fs::create_dir_all(&dir).unwrap(); + let own = dir.join("photo.png").to_string_lossy().into_owned(); + std::fs::write(&own, b"original").unwrap(); + let (mut app, open, _) = app(); + app.run("prefs.set", json!({"path": "export.quickExportLocation", "value": "sameFolder"})).unwrap(); + app.session.active_mut().unwrap().path = Some(own.clone()); + let ctx = egui::Context::default(); + menus::invoke(&mut app, &ctx, "file.export.quickExportAsPng", Value::Null).unwrap(); + app.poll_file_dialog(&ctx, None); + assert_eq!(open.borrow().len(), 1, "the save dialog asks"); + assert_eq!(std::fs::read(&own).unwrap(), b"original", "nothing written before the answer"); + answer(&open, Some(FileDialogAnswer::SaveTo(own.clone()))); + app.poll_file_dialog(&ctx, None); + assert!(std::fs::read(&own).unwrap().starts_with(b"\x89PNG\r\n\x1a\n"), "replaced once confirmed: {}", app.ui.status); + + // Beside a PSD, Same Folder still writes without a prompt. + app.session.active_mut().unwrap().path = Some(dir.join("photo.psd").to_string_lossy().into_owned()); + std::fs::write(&own, b"earlier export").unwrap(); + menus::invoke(&mut app, &ctx, "file.export.quickExportAsPng", Value::Null).unwrap(); + app.poll_file_dialog(&ctx, None); + assert!(open.borrow().is_empty(), "no dialog"); + assert!(std::fs::read(&own).unwrap().starts_with(b"\x89PNG\r\n\x1a\n")); + std::fs::remove_dir_all(&dir).ok(); +} + #[test] fn cancelling_multi_layer_quick_export_writes_nothing() { let (mut app, open, written) = app(); From e196d8e47c24f60b34ce336bba467416797bf912 Mon Sep 17 00:00:00 2001 From: Marco Arnulfo <103679419+marcoarnulfo@users.noreply.github.com> Date: Sat, 17 Oct 2026 19:53:21 +0200 Subject: [PATCH 77/85] Opening a file that is already open shows its tab instead of a second copy (fixes #6598) (#6630) --- crates/ui-egui/src/file_open.rs | 47 ++++++++++++++++++++++ crates/ui-egui/src/file_open/tests.rs | 58 +++++++++++++++++++++++++++ crates/ui-egui/src/menus.rs | 6 ++- 3 files changed, 109 insertions(+), 2 deletions(-) diff --git a/crates/ui-egui/src/file_open.rs b/crates/ui-egui/src/file_open.rs index d752f7ce76..53e908bec9 100644 --- a/crates/ui-egui/src/file_open.rs +++ b/crates/ui-egui/src/file_open.rs @@ -107,6 +107,9 @@ impl PhotocraftApp { /// Read and open the file at `path` (see [`open_file`](Self::open_file)). #[cfg(not(target_arch = "wasm32"))] pub fn open_path(&mut self, path: &str) -> Result, String> { + if self.show_if_open(path) { + return Ok(Vec::new()); + } // Documents opening in the background are read on the worker too (a 2 GB PSB read // would block the window). Preset files (brushes, gradients) go the usual way. let ext = std::path::Path::new(path).extension().map(|e| e.to_string_lossy().to_ascii_lowercase()).unwrap_or_default(); @@ -133,6 +136,10 @@ impl PhotocraftApp { let batch = crate::jobs_ui::begin_batch(self); let mut opened = 0; for (seq, abs) in paths.iter().enumerate() { + // Not a member of the set: its tab is already in place. + if self.show_if_open(abs) { + continue; + } let order = crate::jobs_ui::OpenOrder { batch, seq }; let pending = self.jobs.opens.len(); match self.open_path(abs) { @@ -152,6 +159,26 @@ impl PhotocraftApp { opened } + /// Show the document already open from the file at `path`, or its tab while it is still + /// opening in the background, as Photoshop does, rather than open a second copy bound to the + /// same file (#6598). True when there was one. + pub(crate) fn show_if_open(&mut self, path: &str) -> bool { + let canon = canonical(path); + let same = |p: &str| same_file(p, path, canon.as_deref()); + if let Some(i) = self.session.documents().iter().position(|d| d.path.as_deref().is_some_and(same)) { + self.session.set_active(i); + self.jobs.focus = None; + } else if let Some(o) = self.jobs.opens.iter().find(|o| o.path.as_deref().is_some_and(|p| !is_template(p) && same(p))) { + // A template still loading opens as another Untitled, as it does once loaded. + self.jobs.focus = Some(o.job); + } else { + return false; + } + // The document count didn't change, so the Home screen wouldn't give way by itself. + self.ui.chrome.home = None; + true + } + /// Show "Couldn't open : " as an error (status bar + notice). pub fn open_failed(&mut self, name: &str, err: &str) { self.failed({ @@ -338,5 +365,25 @@ fn absolute(p: &str) -> String { p.to_string() } +/// `path` with symlinks, `.` and `..` and (on case-insensitive volumes) letter case resolved; +/// `None` when it can't be (a missing file). +#[cfg(not(target_arch = "wasm32"))] +fn canonical(path: &str) -> Option { + std::fs::canonicalize(path).ok() +} + +#[cfg(target_arch = "wasm32")] +fn canonical(_path: &str) -> Option { + None +} + +/// Whether an open document's path `p` names the file at `path` (`canon`: its [`canonical`] +/// form). The cheap comparisons come first: `p` is resolved on disk only when they fail, so an +/// open of a file that isn't open costs one resolution per document. A file moved or deleted +/// since it was opened only matches its old path as written. +fn same_file(p: &str, path: &str, canon: Option<&std::path::Path>) -> bool { + p == path || canon.is_some_and(|c| std::path::Path::new(p) == c || canonical(p).is_some_and(|q| q == c)) +} + #[cfg(test)] mod tests; diff --git a/crates/ui-egui/src/file_open/tests.rs b/crates/ui-egui/src/file_open/tests.rs index 4d6581cd6d..b43a10a642 100644 --- a/crates/ui-egui/src/file_open/tests.rs +++ b/crates/ui-egui/src/file_open/tests.rs @@ -928,3 +928,61 @@ fn opening_type_in_a_missing_font_warns() { // The importer's own warning for the same font isn't doubled. assert_eq!(app.open_bytes("warned.psd", b"x").unwrap(), vec![warning]); } + +/// #6598: opening a file that is already open shows its document, as Photoshop does, rather than +/// a second copy bound to the same file (saving one would overwrite the other). The path is +/// compared as the file it names: another spelling of it, a symlink to it or, with background +/// opens, a file still opening finds the same tab. +#[cfg(not(target_arch = "wasm32"))] +#[test] +fn opening_an_open_file_again_shows_its_tab() { + let (dir, paths) = files("already-open", &["a.psd", "b.psd"], b"x"); + let (a, b) = (&paths[0], &paths[1]); + let (mut app, _) = app_with(Vec::new()); + app.open_path(a).unwrap(); + app.open_path(b).unwrap(); + app.run("layer.new.layer", json!({})).unwrap(); + app.ui.chrome.home = Some(2); + // Open Recent, File › Open, a drop: all go through `open_path`. + #[allow(unused_mut)] + let mut spellings = vec![a.clone(), dir.join(".").join("a.psd").to_string_lossy().into_owned()]; + #[cfg(unix)] + { + let link = dir.join("link.psd"); + std::os::unix::fs::symlink(a, &link).unwrap(); + spellings.push(link.to_string_lossy().into_owned()); + } + for again in spellings { + app.session.set_active(1); + app.ui.chrome.home = Some(2); + app.open_path(&again).unwrap(); + assert_eq!(doc_names(&app), ["a.psd", "b.psd"], "{again}"); + assert_eq!(app.session.active_index(), Some(0), "{again}"); + assert_eq!(app.ui.chrome.home, None, "the Home screen gives way to the document"); + } + assert!(app.session.documents()[1].is_dirty(), "b keeps its unsaved changes"); + // A set holding an open file opens only the others. + let (dir2, more) = files("already-open-set", &["c.psd"], b"x"); + assert_eq!(app.open_paths(&[b.clone(), more[0].clone()]), 1); + assert_eq!(doc_names(&app), ["a.psd", "b.psd", "c.psd"]); + // In the background: an open document, then a file still opening, show their tabs. + app.background_jobs = true; + app.open_path(a).unwrap(); + assert!(app.jobs.opens.is_empty()); + assert_eq!(app.session.active_index(), Some(0)); + let (dir3, late) = files("already-open-bg", &["d.psd"], b"x"); + app.open_path(&late[0]).unwrap(); + let job = app.jobs.opens[0].job; + app.jobs.focus = None; + app.open_path(&late[0]).unwrap(); + assert_eq!(app.jobs.opens.len(), 1, "one tab for the file still opening"); + assert_eq!(app.jobs.focus, Some(job)); + // A template still loading opens again as another Untitled, as it does once loaded. + let (dir4, template) = files("already-open-template", &["e.psdt"], b"x"); + app.open_path(&template[0]).unwrap(); + app.open_path(&template[0]).unwrap(); + assert_eq!(app.jobs.opens.len(), 3, "two Untitleds from the template"); + for d in [dir, dir2, dir3, dir4] { + let _ = std::fs::remove_dir_all(&d); + } +} diff --git a/crates/ui-egui/src/menus.rs b/crates/ui-egui/src/menus.rs index d322e26418..074d87a8b0 100644 --- a/crates/ui-egui/src/menus.rs +++ b/crates/ui-egui/src/menus.rs @@ -2090,9 +2090,11 @@ mod open_recent_tests { let ctx = egui::Context::default(); invoke(&mut app, &ctx, "file.open", json!({ "path": p })).unwrap(); assert_eq!(app.ui.recent_files.first().map(String::as_str), Some(p.as_str())); - // Re-open via the recent entry. + // Re-open via the recent entry once closed (an open file shows its tab instead, #6598). + app.session.close(0); invoke(&mut app, &ctx, "file.openRecent.0", json!({})).unwrap(); - assert_eq!(app.session.documents().len(), 2); + assert_eq!(app.session.documents().len(), 1); + assert_eq!(app.session.active().and_then(|d| d.path.as_deref()), Some(p.as_str())); let _ = std::fs::remove_file(&path); } } From c9899e9c2942323b6c830bdb5f37005bb4d99125 Mon Sep 17 00:00:00 2001 From: David Mann Date: Sat, 17 Oct 2026 19:55:31 +0200 Subject: [PATCH 78/85] Duotone: ink curve points must be finite (#7049) (#7050) Co-authored-by: Claude Opus 5.5 --- crates/engine/src/mode_cmds.rs | 4 ++++ crates/engine/src/mode_cmds/tests.rs | 14 ++++++++++++++ 2 files changed, 18 insertions(+) diff --git a/crates/engine/src/mode_cmds.rs b/crates/engine/src/mode_cmds.rs index fe22a192c7..840f1c6096 100644 --- a/crates/engine/src/mode_cmds.rs +++ b/crates/engine/src/mode_cmds.rs @@ -444,6 +444,10 @@ fn parse_inks(cmd: &str, v: &Value, n: usize) -> Result> { if let Some(pts) = ink.get("curve").and_then(Value::as_array) { let mut curve: Vec = pts.iter().filter_map(|p| Some(CurvePoint { input: p.get(0)?.as_f64()? as f32 / 100.0, output: p.get(1)?.as_f64()? as f32 / 100.0 })).collect(); + // 1e39 is a valid JSON number but inf as f32, and a .pcraft with an inf curve can't be saved. + if curve.iter().any(|p| !p.input.is_finite() || !p.output.is_finite()) { + return Err(bad(cmd, "curve points must be finite numbers 0..100")); + } curve.sort_by(|a, b| a.input.total_cmp(&b.input)); if curve.len() >= 2 { d.curve = curve; diff --git a/crates/engine/src/mode_cmds/tests.rs b/crates/engine/src/mode_cmds/tests.rs index d6d020699e..a78a11db7d 100644 --- a/crates/engine/src/mode_cmds/tests.rs +++ b/crates/engine/src/mode_cmds/tests.rs @@ -353,6 +353,20 @@ fn duotone_inks_and_display() { assert!(!session(4, 4, 8, "rgb").is_enabled("image.mode.duotone")); } +#[test] +fn duotone_curve_points_must_be_finite() { + let mut s = session(4, 4, 8, "gray"); + // 1e39 overflows f32 to inf, which the .pcraft writer refuses to save. + for curve in [json!([[0, 0], [100, 1e39]]), json!([[0, 0], [1e39, 50]]), json!([[-1e39, 0], [100, 100]])] { + let r = s.execute("image.mode.duotone", json!({"type": "monotone", "inks": [{"curve": curve}]})); + assert!(r.is_err(), "{curve} accepted"); + assert_eq!(doc(&s).mode, ColorMode::Grayscale); + } + s.execute("image.mode.duotone", json!({"type": "monotone", "inks": [{"curve": [[0, 0], [50, 70], [100, 100]]}]})).unwrap(); + assert_eq!(doc(&s).mode, ColorMode::Duotone); + photocraft_format::save_to_bytes(doc(&s), &Default::default()).unwrap(); +} + #[test] fn modes_round_trip_through_pcraft() { let mut s = session(12, 12, 8, "rgb"); From 589941a536bff8bd7bdea6d4b9be765ab94644a3 Mon Sep 17 00:00:00 2001 From: David Mann Date: Sat, 17 Oct 2026 19:55:43 +0200 Subject: [PATCH 79/85] Save: refuse to replace a read-only file (#6600) (#6643) Saving writes a temporary file and renames it over the destination. A rename only needs the folder to be writable, so on Linux and macOS a read-only (chmod 444) file was silently replaced, while on Windows the rename failed with a vague "locked or read-only" access error. atomic_write now asks the AtomicFs whether the existing destination is read-only (new `is_readonly`, false by default for other implementors) and refuses before anything is written: "cannot save : the file is read-only; the original file was not changed". New files and writable files save as before. Photoshop refuses the same way; the user saves a copy with Save As. Tests: a fake AtomicFs reporting read-only gets the error with nothing written or renamed; a real file marked read-only keeps its bytes and mode and gets the clear message on every platform. Co-authored-by: Claude Opus 5.5 (1M context) --- crates/format/src/atomic.rs | 16 +++++++ crates/format/src/atomic/tests.rs | 75 +++++++++++++++++++++++++++++++ 2 files changed, 91 insertions(+) diff --git a/crates/format/src/atomic.rs b/crates/format/src/atomic.rs index bf39fe2761..348374d3cf 100644 --- a/crates/format/src/atomic.rs +++ b/crates/format/src/atomic.rs @@ -13,6 +13,10 @@ //! if it does anyway, the save fails with a clear error. It never falls back to writing the //! destination in place. //! - **Unwritable folder:** the save fails with a clear error instead of writing in place. +//! - **Read-only file:** the save is refused before anything is written (#6600). A rename only +//! needs the folder to be writable, so without this check Linux and macOS would silently replace +//! a `chmod 444` file while Windows refuses; now every platform refuses, as Photoshop does, and +//! the user saves a copy with Save As. //! - **Network shares (macOS):** `sync_all` asks for a full flush to the disk's platters //! (`F_FULLFSYNC`), which SMB shares and some virtual file systems refuse while a plain `fsync` //! works. [`sync_file`] falls back to the plain one there (#1336). @@ -30,6 +34,12 @@ use std::sync::atomic::{AtomicU64, Ordering}; /// The file-system steps [`atomic_write_with`] performs. [`RealFs`] is the real thing; tests /// wrap it to inject failures. pub trait AtomicFs { + /// Whether the existing file at `path` is marked read-only (a missing file is not). A + /// read-only destination is never replaced (#6600). By default nothing is read-only. + fn is_readonly(&self, path: &Path) -> bool { + let _ = path; + false + } /// Create `tmp` (it must not exist), write all of `bytes` and flush it ([`sync_file`]). fn write_new(&self, tmp: &Path, bytes: &[u8]) -> io::Result<()>; /// [`write_new`](Self::write_new), reporting the fraction of `bytes` written so far to @@ -91,6 +101,9 @@ impl AtomicFs for RealFs { let perms = std::fs::metadata(from)?.permissions(); std::fs::set_permissions(to, perms) } + fn is_readonly(&self, path: &Path) -> bool { + std::fs::metadata(path).is_ok_and(|m| m.is_file() && m.permissions().readonly()) + } } /// Flush `file` to stable storage (`sync_all`). On Apple platforms that is a full flush @@ -169,6 +182,9 @@ pub fn atomic_write_with_progress(fs: &dyn AtomicFs, retry: RenameRetry, path: & _ => PathBuf::from("."), }; std::fs::create_dir_all(&dir).map_err(|e| context(e, format!("cannot create the folder {}", dir.display())))?; + if fs.is_readonly(&target) { + return Err(io::Error::new(io::ErrorKind::PermissionDenied, format!("cannot save {}: the file is read-only; {UNCHANGED}", path.display()))); + } // Create a fresh temporary file next to the target (same directory, so the rename never // crosses a device). The name is unique per process and call; retry a few on collisions. diff --git a/crates/format/src/atomic/tests.rs b/crates/format/src/atomic/tests.rs index 8e71bc7ce3..a85f998a16 100644 --- a/crates/format/src/atomic/tests.rs +++ b/crates/format/src/atomic/tests.rs @@ -176,6 +176,81 @@ fn lock_that_outlasts_the_retries_keeps_original() { assert_no_temp(&d.0); } +/// A file system that reports the target as read-only and records every write and rename. +#[derive(Default)] +struct ReadOnlyTarget { + writes: Cell, + renames: Cell, +} + +impl AtomicFs for ReadOnlyTarget { + fn is_readonly(&self, _path: &Path) -> bool { + true + } + fn write_new(&self, tmp: &Path, bytes: &[u8]) -> io::Result<()> { + self.writes.set(self.writes.get() + 1); + RealFs.write_new(tmp, bytes) + } + fn rename(&self, from: &Path, to: &Path) -> io::Result<()> { + self.renames.set(self.renames.get() + 1); + RealFs.rename(from, to) + } + fn remove(&self, path: &Path) -> io::Result<()> { + RealFs.remove(path) + } + fn sync_dir(&self, dir: &Path) -> io::Result<()> { + RealFs.sync_dir(dir) + } + fn copy_permissions(&self, from: &Path, to: &Path) -> io::Result<()> { + RealFs.copy_permissions(from, to) + } +} + +/// #6600: a read-only destination is refused before anything is written, with an error that says +/// so, instead of being replaced by the rename (which only needs the folder to be writable). +#[test] +fn read_only_target_is_refused_before_anything_is_written() { + let d = TempDir::new("rofake"); + let p = d.0.join("a.psd"); + std::fs::write(&p, ORIGINAL).unwrap(); + let fs = ReadOnlyTarget::default(); + let e = atomic_write_with(&fs, NO_RETRY, &p, b"new").unwrap_err(); + assert_eq!(e.kind(), io::ErrorKind::PermissionDenied); + assert!(e.to_string().contains("a.psd") && e.to_string().contains("read-only") && e.to_string().contains("not changed"), "{e}"); + assert_eq!((fs.writes.get(), fs.renames.get()), (0, 0), "nothing is written or renamed"); + assert_eq!(std::fs::read(&p).unwrap(), ORIGINAL); + assert_no_temp(&d.0); +} + +/// #6600 on the real file system: a file marked read-only (`chmod 444`, or the Windows read-only +/// attribute) keeps its bytes and the save says why; a writable one still saves. +#[test] +fn read_only_file_on_disk_is_never_replaced() { + let d = TempDir::new("rofile"); + let p = d.0.join("a.psd"); + std::fs::write(&p, ORIGINAL).unwrap(); + let mut perms = std::fs::metadata(&p).unwrap().permissions(); + perms.set_readonly(true); + std::fs::set_permissions(&p, perms.clone()).unwrap(); + let r = atomic_write(&p, b"new"); + let unchanged = std::fs::read(&p).unwrap(); + let still_readonly = std::fs::metadata(&p).unwrap().permissions().readonly(); + // Make it writable again so the folder can be removed (Windows refuses to delete it otherwise). + #[allow(clippy::permissions_set_readonly_false)] + perms.set_readonly(false); + std::fs::set_permissions(&p, perms).unwrap(); + let e = r.unwrap_err(); + assert_eq!(e.kind(), io::ErrorKind::PermissionDenied); + assert!(e.to_string().contains("the file is read-only;"), "a clear reason, not a failed rename: {e}"); + assert_eq!(unchanged, ORIGINAL); + assert!(still_readonly); + assert_no_temp(&d.0); + // New files and writable files are unaffected. + atomic_write(&p, b"new").unwrap(); + assert_eq!(std::fs::read(&p).unwrap(), b"new"); + atomic_write(&d.0.join("fresh.psd"), b"x").unwrap(); +} + #[cfg(unix)] #[test] fn read_only_folder_errors_clearly_and_never_writes_in_place() { From 796ee3b1039fa35bac67639c5542669333478299 Mon Sep 17 00:00:00 2001 From: Zuujis <160198409+zuujiscom@users.noreply.github.com> Date: Sat, 17 Oct 2026 12:55:54 -0500 Subject: [PATCH 80/85] Image Assets: two layers naming the same file no longer overwrite each other (#6614) (#6668) * Image Assets: two layers naming the same file no longer overwrite each other (#6614) [WIP] Co-Authored-By: Claude Opus 5.5 * Image Assets: two layers naming the same file no longer overwrite each other (#6614) generate_assets wrote every requested asset, so two layers (or two default variants) naming one file silently replaced each other and the result listed the file twice. Each file is now written once per run: layers go top first, as the Layers panel lists them, the first writer keeps the name, and later ones are reported in errors. Names are compared ignoring case, since Logo.png and logo.png are one file on Windows and macOS. Co-Authored-By: Claude Opus 5.5 --------- Co-authored-by: Claude Opus 5.5 --- crates/engine/src/web_cmds.rs | 21 +++++++++++---- crates/engine/src/web_cmds/tests.rs | 41 +++++++++++++++++++++++++++++ 2 files changed, 57 insertions(+), 5 deletions(-) diff --git a/crates/engine/src/web_cmds.rs b/crates/engine/src/web_cmds.rs index 25cd235b2c..e5006daa8f 100644 --- a/crates/engine/src/web_cmds.rs +++ b/crates/engine/src/web_cmds.rs @@ -1358,10 +1358,15 @@ fn asset_output_path(dir: &str, relative: &str) -> Result<(String, String)> { } /// Exports every asset named by `doc`'s layers into `dir`. Returns (files, errors). +/// +/// Each file is written once per run (#6614): when two layers (or two default variants) name the +/// same file, the one higher in the Layers panel keeps it and the other is reported in `errors`. +/// Names are compared ignoring case, as `Logo.png` and `logo.png` are one file on Windows and +/// macOS. pub fn generate_assets(doc: &Document, dir: &str) -> (Vec, Vec) { let dpi = f64::from(doc.resolution_dpi.max(1.0)); let mut defaults: Option> = None; - let mut jobs: Vec<(LayerId, AssetSpec)> = Vec::new(); + let mut by_layer: Vec> = Vec::new(); for (_, _, l) in doc.walk() { if let Some(d) = parse_defaults(&l.name, dpi) { defaults = Some(d); @@ -1370,22 +1375,26 @@ pub fn generate_assets(doc: &Document, dir: &str) -> (Vec, Vec) { if matches!(l.content, LayerContent::Adjustment(_)) { continue; } - for spec in parse_asset_name(&l.name, dpi) { - jobs.push((l.id, spec)); - } + by_layer.push(parse_asset_name(&l.name, dpi).into_iter().map(|spec| (l.id, spec)).collect()); } + // Top layer first, as the Layers panel lists them (`walk` goes bottom to top). + let jobs: Vec<(LayerId, AssetSpec)> = by_layer.into_iter().rev().flatten().collect(); let variants: Vec = match defaults { Some(d) if !d.is_empty() => d, _ => vec![AssetDefault::default()], }; let mut files = Vec::new(); let mut errors = Vec::new(); + let mut written = std::collections::HashSet::new(); for (id, spec) in &jobs { for v in &variants { let r = (|| -> Result { let (name_stem, ext) = spec.file.rsplit_once('.').unwrap_or((spec.file.as_str(), "png")); let rel = format!("{}{}{}.{ext}", v.folder, name_stem, v.suffix); - asset_path_components(&rel)?; + let key = asset_path_components(&rel)?.join("/").to_lowercase(); + if written.contains(&key) { + return Err(other(format!("{rel}: another layer already writes this file; rename one of the layers"))); + } let mut one = crate::layer_menu_cmds::layer_document(doc, *id)?; one.metadata = Default::default(); let (w0, h0) = (f64::from(one.size.width), f64::from(one.size.height)); @@ -1413,12 +1422,14 @@ pub fn generate_assets(doc: &Document, dir: &str) -> (Vec, Vec) { if spec.format == "webp" { let (out, reported) = asset_output_path(dir, &rel)?; crate::file_cmds::save_doc(&wdoc, &out, None)?; + written.insert(key); return Ok(reported); } let buf = photocraft_compose::flatten(&wdoc); let o = optimize(&buf.px, wdoc.size.width as usize, wdoc.bounds(), &st, None, None, wdoc.resolution_dpi, false)?; let (out, reported) = asset_output_path(dir, &rel)?; write_file(&out, &o.bytes)?; + written.insert(key); Ok(reported) })(); match r { diff --git a/crates/engine/src/web_cmds/tests.rs b/crates/engine/src/web_cmds/tests.rs index 1b91dcf726..7bb6fe5646 100644 --- a/crates/engine/src/web_cmds/tests.rs +++ b/crates/engine/src/web_cmds/tests.rs @@ -594,6 +594,47 @@ fn image_assets_are_generated_now_and_on_save() { assert_eq!(decode(&files[0]).dimensions(), (128, 80)); } +/// #6614: two layers, or two default variants, naming one file never overwrite each other: the +/// layer higher in the Layers panel keeps the file, whatever the case of its name, and the +/// others are reported. +#[test] +fn image_assets_never_write_one_file_twice() { + let dir = tmp("asset-collisions"); + let mut s = session(8); + s.edit("paint", |doc, active| { + let fmt = doc.pixel_format(); + let mut l = Layer::raster("Top", fmt); + l.surface_mut().unwrap().fill_rect(Rect::new(0, 0, 4, 4), &photocraft_raster::from_rgba(&fmt, [0.0, 0.0, 1.0, 1.0])); + let id = doc.insert_above(None, l); + *active = Some(id); + Ok(()) + }) + .unwrap(); + let mut d = (*s.active().unwrap().doc).clone(); + let id = |d: &photocraft_doc::Document, name: &str| d.walk().into_iter().find(|(_, _, l)| l.name == name).unwrap().2.id; + let (top, logo) = (id(&d, "Top"), id(&d, "Logo")); + d.layer_mut(top).unwrap().name = "Logo.PNG".into(); + d.layer_mut(logo).unwrap().name = "logo.png, logo.png".into(); + let (files, errors) = generate_assets(&d, &dir); + assert_eq!(files.iter().map(|p| std::path::PathBuf::from(p.as_str())).collect::>(), vec![std::path::Path::new(&dir).join("Logo.png")]); + assert_eq!(decode(&files[0]).dimensions(), (4, 4), "the top layer keeps the file"); + assert_eq!(errors.len(), 2, "{errors:?}"); + assert!(errors.iter().all(|e| e["error"].as_str().unwrap().contains("another layer already writes this file")), "{errors:?}"); + assert_eq!(std::fs::read_dir(&dir).unwrap().count(), 1, "one file, not a second one under another case"); + + // Two default variants that land on one name: the first keeps it. + let dir = tmp("asset-variant-collisions"); + d.layer_mut(top).unwrap().name = "icon.png".into(); + d.layer_mut(logo).unwrap().name = "Logo".into(); + d.layers + .push(Layer::new("default 50% + 200% + 200% @2x", LayerContent::Group(photocraft_doc::Group { children: vec![], expanded: false, artboard: None }))); + let (files, errors) = generate_assets(&d, &dir); + let names: Vec<_> = files.iter().map(|p| std::path::Path::new(p).file_name().unwrap().to_string_lossy().into_owned()).collect(); + assert_eq!(names, ["icon.png", "icon@2x.png"]); + assert_eq!(decode(&files[0]).dimensions(), (2, 2), "the 50% variant keeps icon.png"); + assert_eq!(errors.len(), 1, "{errors:?}"); +} + #[test] fn image_assets_reject_traversal_from_names_and_defaults() { let base = tmp("asset-path-guard"); From 378231214a69c779150be82e4ee89760a166d165 Mon Sep 17 00:00:00 2001 From: Khawar Zamman Wani Date: Sat, 17 Oct 2026 23:27:27 +0530 Subject: [PATCH 81/85] Desktop app: `photocraft --help` prints usage; unknown options are usage errors (#6431) (#6435) * fix(desktop): `photocraft --help` prints usage; unknown options are usage errors (#6431) The desktop binary's argument loop took every argument it didn't know as a file, so `photocraft --help`, `-h` or a typo such as `--safegpu` opened the window and logged "Couldn't open --help". - `--help` / `-h` print the usage (every option and environment variable) and exit 0. - Any other argument starting with `-` (except macOS `-psn_`) is named on stderr and the launch exits 2 before the log file or window, along with any bad `--control` port (`control_args_exit_code` becomes `usage_exit_code`). - `--` ends the options, so a file whose name starts with `-` opens. - `--help` and `--version` write without `println!`, so a closed pipe (`photocraft --help | head -1`) can't panic. Tests: apps/photocraft/tests/command_line_usage.rs runs the binary with no display; 4 of its 6 tests fail on the old code. Co-Authored-By: Claude Opus 5.5 * test(desktop): usage tests exit before the window opens; fmt smart_filter_authorization `arguments_after_double_dash_are_files` and `macos_process_serial_number_is_still_ignored` passed only valid arguments, so the app went on to open its window. Removing DISPLAY makes that fail fast on Linux, but Windows runners have a desktop and both tests hung until nextest's 600 s timeout. They now add a bad `--control` value so the launch always fails with the usage errors, and assert stderr names only that error (no "unknown option"). Also `cargo fmt` crates/automation/tests/smart_filter_authorization.rs, left unformatted on main by #5684, which failed the Format step of every PR's ubuntu test job. Co-Authored-By: Claude Opus 5.5 --------- Co-authored-by: Claude Opus 5.5 --- apps/photocraft/src/main.rs | 102 ++++++++++++++++---- apps/photocraft/tests/command_line_usage.rs | 95 ++++++++++++++++++ 2 files changed, 179 insertions(+), 18 deletions(-) create mode 100644 apps/photocraft/tests/command_line_usage.rs diff --git a/apps/photocraft/src/main.rs b/apps/photocraft/src/main.rs index abd70eb418..721bbc7349 100644 --- a/apps/photocraft/src/main.rs +++ b/apps/photocraft/src/main.rs @@ -3,7 +3,12 @@ //! 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 ] [--new-instance] [files…]` +//! [--frame-latency ] [--stroke-trace ] [--new-instance] [--] [files…]` +//! (`photocraft --help` prints [`USAGE`]; `--version` prints the version). +//! +//! Any other argument starting with `-` is a usage error (exit status 2, issue #6431), so a typo +//! such as `--safegpu` doesn't open the window and then fail to open the flag as a file. A file +//! whose name starts with `-` goes after `--`. //! //! 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 @@ -149,21 +154,63 @@ fn parse_control_port(value: &str, source: &str) -> Result { value.trim().parse().map_err(|_| format!("{source}: `{value}` is not a valid port (expected a number from 0 to 65535)")) } -/// Process exit status for the collected `--control` / `PHOTOCRAFT_CONTROL_PORT` errors: `None` -/// when there are none, else 2 (a command-line usage error), never 0. -fn control_args_exit_code(errors: &[String]) -> Option { +/// Process exit status for the collected usage errors (a bad `--control` / +/// `PHOTOCRAFT_CONTROL_PORT`, an unknown option): `None` when there are none, else 2 (a +/// command-line usage error), never 0. +fn usage_exit_code(errors: &[String]) -> Option { if errors.is_empty() { None } else { Some(2) } } +/// `photocraft --help`. Keep in step with the options `main` accepts and the module docs. +const USAGE: &str = "\ +Usage: photocraft [options] [--] [files…] + +Opens PhotoCraft with the given files. + +Options: + -h, --help Print this help and exit + --version Print the version, commit and build date and exit + --safe-gpu Start with the CPU renderer for this launch (after a graphics driver crash) + --in-window-menus macOS: keep the menus inside the window instead of the menu bar + --new-instance Windows and Linux: start a separate PhotoCraft instead of opening + the files in the one already running + --present-mode How frames reach the display: fifo, autovsync or novsync + --frame-latency Frames queued for the display: 1 to 3 + --stroke-trace + Time each brush stroke's frames + --control Start the localhost JSON-lines control server on this port + --control-token <64-hex> Control server token (default: a new random one, printed) + --control-token-file Read the control server token from this file, or create it + with a new one + --automation-read-root Directory the control server may read files from + --automation-write-root + Directory the control server may write files to + -- Treat every later argument as a file, even one starting with `-` + +A bad --present-mode, --frame-latency or --stroke-trace value is reported and ignored. +Environment: PHOTOCRAFT_CONTROL_PORT, PHOTOCRAFT_CONTROL_TOKEN, PHOTOCRAFT_CONTROL_TOKEN_FILE, +PHOTOCRAFT_AUTOMATION_READ_ROOT, PHOTOCRAFT_AUTOMATION_WRITE_ROOT, PHOTOCRAFT_PRESENT_MODE, +PHOTOCRAFT_FRAME_LATENCY, PHOTOCRAFT_STROKE_TRACE, PHOTOCRAFT_IN_WINDOW_MENUS, PHOTOCRAFT_NEW_INSTANCE, +PHOTOCRAFT_CONFIG_DIR. +"; + +/// Print to standard output for `--help` / `--version`. Unlike `println!`, never panics when +/// stdout is closed or a pipe (`photocraft --help | head -1`). +fn print_stdout(text: &str) { + use std::io::Write as _; + let mut out = std::io::stdout().lock(); + let _ = out.write_all(text.as_bytes()).and_then(|()| out.flush()); +} + #[cfg(test)] mod control_port_tests { - use super::{control_args_exit_code, parse_control_port}; + use super::{parse_control_port, usage_exit_code}; #[test] fn control_port_errors_exit_non_zero() { - assert_eq!(control_args_exit_code(&[]), None); + assert_eq!(usage_exit_code(&[]), None); let errors = vec![parse_control_port("nope", "--control").unwrap_err()]; - assert_eq!(control_args_exit_code(&errors), Some(2)); + assert_eq!(usage_exit_code(&errors), Some(2)); } #[test] @@ -191,11 +238,11 @@ fn main() -> eframe::Result { // The system font scan can take seconds (minutes on some Macs): run it off the UI thread. photocraft_ui_egui::type_tool::warm_fonts(); let mut control_port: Option = None; - let mut control_arg_errors: Vec = Vec::new(); + let mut usage_errors: Vec = Vec::new(); if let Some(value) = std::env::var("PHOTOCRAFT_CONTROL_PORT").ok().filter(|v| !v.trim().is_empty()) { match parse_control_port(&value, "PHOTOCRAFT_CONTROL_PORT") { Ok(port) => control_port = Some(port), - Err(error) => control_arg_errors.push(error), + Err(error) => usage_errors.push(error), } } let mut control_token = None; @@ -214,18 +261,32 @@ fn main() -> eframe::Result { 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); + // After `--`, every argument is a file, even one that starts with `-`. + let mut only_files = false; while let Some(a) = args.next() { + if only_files { + match a.into_string() { + Ok(s) => files.push(s), + Err(raw) => unreadable_paths.push(raw.to_string_lossy().into_owned()), + } + continue; + } match a.to_str() { + Some("--") => only_files = true, + Some("--help" | "-h") => { + print_stdout(USAGE); + return Ok(()); + } Some("--control") => match args.next() { Some(value) => match parse_control_port(&value.to_string_lossy(), "--control") { Ok(port) => control_port = Some(port), - Err(error) => control_arg_errors.push(error), + Err(error) => usage_errors.push(error), }, - None => control_arg_errors.push("--control: missing port value (expected `--control `)".to_string()), + None => usage_errors.push("--control: missing port value (expected `--control `)".to_string()), }, Some("--control-token") => match args.next().map(std::ffi::OsString::into_string) { Some(Ok(token)) => control_token = Some(token), - Some(Err(raw)) => control_arg_errors.push(format!("--control-token: value is not valid Unicode (`{}`)", raw.to_string_lossy())), + Some(Err(raw)) => usage_errors.push(format!("--control-token: value is not valid Unicode (`{}`)", raw.to_string_lossy())), None => control_token = None, }, Some("--control-token-file") => control_token_file = args.next().map(std::path::PathBuf::from), @@ -264,23 +325,28 @@ fn main() -> eframe::Result { } Some("--new-instance") => new_instance = true, Some("--version") => { - println!("photocraft {}", photocraft_engine::build_info::long_version()); + print_stdout(&format!("photocraft {}\n", photocraft_engine::build_info::long_version())); return Ok(()); } // Old macOS passes a process serial number when launched from Finder. Some(s) if s.starts_with("-psn_") => {} + // A typo such as `--safegpu`, or `--help` spelt another way, must not open the window + // and then fail to open the flag as a file (#6431). + Some(s) if s.starts_with('-') => usage_errors.push(format!("unknown option `{s}` (put `--` before a file name that starts with `-`)")), Some(s) => files.push(s.to_owned()), None => unreadable_paths.push(a.to_string_lossy().into_owned()), } } - // A malformed control port must not silently drop the control server (issue #701): name the - // bad value and fail the launch, like `photocraft-cli serve --port` does for the same typo. - // Exit status 2 is the usual command-line usage error, so a launcher sees the failure. - if let Some(code) = control_args_exit_code(&control_arg_errors) { - for error in &control_arg_errors { + // A malformed control port must not silently drop the control server (issue #701), and an + // unknown option must not open the window (#6431): name the bad argument and fail the launch, + // like `photocraft-cli` does for the same typos. Exit status 2 is the usual command-line usage + // error, so a launcher sees the failure. + if let Some(code) = usage_exit_code(&usage_errors) { + for error in &usage_errors { eprintln!("photocraft: {error}"); } + eprintln!("Run `photocraft --help` for usage."); std::process::exit(code); } diff --git a/apps/photocraft/tests/command_line_usage.rs b/apps/photocraft/tests/command_line_usage.rs new file mode 100644 index 0000000000..c4c745ea9a --- /dev/null +++ b/apps/photocraft/tests/command_line_usage.rs @@ -0,0 +1,95 @@ +//! `photocraft --help` and unknown options must not open the window (#6431): before the fix, +//! any argument the loop didn't know was taken as a file, so `--help` or a typo such as +//! `--safegpu` launched the GUI and logged "Couldn't open --help". +//! +//! These run the real binary, and every case must exit before the window opens: Windows and +//! macOS runners have a desktop, so a launch that gets that far hangs until the test times out. +//! Cases whose arguments are valid add a bad `--control` value, which fails the launch with the +//! other usage errors, so stderr shows whether those arguments were taken as unknown options. + +/// Run `photocraft` with `args` in an isolated config dir; return the exit +/// status, stdout and stderr. +fn run_photocraft(args: &[&str]) -> (std::process::ExitStatus, String, String) { + static NEXT: std::sync::atomic::AtomicUsize = std::sync::atomic::AtomicUsize::new(0); + let n = NEXT.fetch_add(1, std::sync::atomic::Ordering::Relaxed); + let dir = std::env::temp_dir().join(format!("photocraft-usage-{}-{n}", std::process::id())); + std::fs::create_dir_all(&dir).unwrap(); + let output = std::process::Command::new(env!("CARGO_BIN_EXE_photocraft")) + .args(args) + .env("PHOTOCRAFT_CONFIG_DIR", &dir) + .env_remove("PHOTOCRAFT_CONTROL_PORT") + .env_remove("DISPLAY") + .env_remove("WAYLAND_DISPLAY") + .env_remove("WAYLAND_SOCKET") + .output() + .expect("failed to run photocraft binary"); + std::fs::remove_dir_all(&dir).ok(); + (output.status, String::from_utf8_lossy(&output.stdout).into_owned(), String::from_utf8_lossy(&output.stderr).into_owned()) +} + +#[test] +fn help_prints_usage_and_exits_0() { + for flag in ["--help", "-h"] { + let (status, stdout, stderr) = run_photocraft(&[flag]); + assert_eq!(status.code(), Some(0), "{flag} must exit 0, stderr: {stderr}"); + assert!(stdout.starts_with("Usage: photocraft"), "{flag} stdout: {stdout}"); + for option in ["--safe-gpu", "--new-instance", "--control ", "--version", "--"] { + assert!(stdout.contains(option), "{flag} usage must list {option}: {stdout}"); + } + assert!(!stderr.contains("Couldn't open"), "{flag} was taken as a file: {stderr}"); + } +} + +#[test] +fn help_wins_over_later_arguments() { + let (status, stdout, _) = run_photocraft(&["--help", "picture.psd", "--safegpu"]); + assert_eq!(status.code(), Some(0)); + assert!(stdout.starts_with("Usage: photocraft"), "stdout: {stdout}"); +} + +#[test] +fn unknown_options_exit_2_naming_them() { + for flag in ["--safegpu", "-x", "--help=yes"] { + let (status, stdout, stderr) = run_photocraft(&[flag]); + assert_eq!(status.code(), Some(2), "{flag} must be a usage error, stderr: {stderr}"); + assert!(stderr.contains(&format!("unknown option `{flag}`")), "stderr: {stderr}"); + assert!(stderr.contains("photocraft --help"), "stderr should point at --help: {stderr}"); + assert!(stdout.is_empty(), "stdout: {stdout}"); + } +} + +#[test] +fn unknown_option_errors_are_reported_with_control_port_errors() { + let (status, _, stderr) = run_photocraft(&["--control", "nope", "--safegpu"]); + assert_eq!(status.code(), Some(2), "stderr: {stderr}"); + assert!(stderr.contains("`nope` is not a valid port"), "stderr: {stderr}"); + assert!(stderr.contains("unknown option `--safegpu`"), "stderr: {stderr}"); +} + +#[test] +fn arguments_after_double_dash_are_files() { + // `--` ends the options: `--help` and `-x` after it are file names, so neither prints usage + // nor is an unknown option; only the bad port is reported. + let (status, stdout, stderr) = run_photocraft(&["--control", "nope", "--", "--help", "-x"]); + assert_eq!(status.code(), Some(2), "stderr: {stderr}"); + assert!(stderr.contains("`nope` is not a valid port"), "stderr: {stderr}"); + assert!(!stdout.contains("Usage:"), "stdout: {stdout}"); + assert!(!stderr.contains("unknown option"), "stderr: {stderr}"); +} + +#[test] +fn macos_process_serial_number_is_still_ignored() { + // Old macOS passes `-psn_` when launching from Finder; it is neither a file nor an error. + let (status, _, stderr) = run_photocraft(&["--control", "nope", "-psn_0_12345"]); + assert_eq!(status.code(), Some(2), "stderr: {stderr}"); + assert!(stderr.contains("`nope` is not a valid port"), "stderr: {stderr}"); + assert!(!stderr.contains("unknown option"), "stderr: {stderr}"); +} + +#[test] +fn new_instance_is_a_known_option() { + let (status, _, stderr) = run_photocraft(&["--control", "nope", "--new-instance"]); + assert_eq!(status.code(), Some(2), "stderr: {stderr}"); + assert!(stderr.contains("`nope` is not a valid port"), "stderr: {stderr}"); + assert!(!stderr.contains("unknown option"), "stderr: {stderr}"); +} From ba40454b18952ee595662a826396e81dac958eae Mon Sep 17 00:00:00 2001 From: Gert Pellin - GPE Date: Sat, 17 Oct 2026 19:57:39 +0200 Subject: [PATCH 82/85] Logging: a second launch leaves the running instance's log alone (#6432) (#6478) logging::rotate renamed photocraft.log up the chain on every start without knowing whether another instance still wrote to it. The running instance kept its handle, so after a second launch its live log was .1.log, after a third .2.log, and a fourth launch deleted it. The instance that writes photocraft.log now holds an exclusive lock on photocraft.lock until it exits (a separate file, since Windows locks are mandatory and would keep the log itself from being opened). A start that finds that lock held leaves the chain alone and writes photocraft-.log, marked by photocraft-.lock. Those side-by-side logs are pruned to the newest KEEP once their instances have exited; a held one is never touched. Rotation and pruning run under rotate.lock so two starts at once can't interleave. On a file system without locks every start rotates as before. --- apps/photocraft/src/logging.rs | 186 ++++++++++++++++++++++++++++++--- docs/development.md | 2 +- 2 files changed, 175 insertions(+), 13 deletions(-) diff --git a/apps/photocraft/src/logging.rs b/apps/photocraft/src/logging.rs index 292428b8ce..33b7df2462 100644 --- a/apps/photocraft/src/logging.rs +++ b/apps/photocraft/src/logging.rs @@ -4,7 +4,9 @@ //! lost device, the crash guard's panic report) vanished. A launch from a desktop menu has no //! terminal, so the file is what a bug report can attach: `/logs/photocraft.log` //! (see `app_dirs`). Each start moves the previous log to `photocraft.1.log` (and that one to -//! `.2`), so the log of a run that crashed survives the next launch. +//! `.2`), so the log of a run that crashed survives the next launch. A start while another +//! instance is still running leaves that instance's log alone and writes `photocraft-.log` +//! (see [`rotate`]). //! //! Levels: `info` for PhotoCraft's own crates, `warn` for everything else (wgpu and naga are //! chatty). `RUST_LOG` replaces that with env_logger-style directives: `debug`, @@ -15,7 +17,7 @@ //! written once the file is attached. Writing never panics: a file that can't be created or //! written leaves standard error as the only sink. -use std::fs::File; +use std::fs::{File, OpenOptions, TryLockError}; use std::io::Write; use std::path::{Path, PathBuf}; use std::sync::{Mutex, OnceLock, PoisonError}; @@ -116,10 +118,76 @@ fn numbered(dir: &Path, n: usize) -> PathBuf { if n == 0 { dir.join(LOG_FILE) } else { dir.join(format!("photocraft.{n}.log")) } } -/// Shift the previous logs up one (`.log` → `.1.log` → … → `..log`, the oldest dropped) -/// and create a fresh, empty log file in `dir` (created if missing). -pub fn rotate(dir: &Path) -> std::io::Result<(PathBuf, File)> { +/// Held by the instance that writes [`LOG_FILE`], from its start until it exits. A separate lock +/// file rather than a lock on the log itself: Windows locks are mandatory, and a locked log could +/// not be opened for a bug report while the app runs. +pub const OWNER_LOCK: &str = "photocraft.lock"; +/// Held while one start rotates and prunes the logs, so two starts at once can't interleave. +pub const ROTATE_LOCK: &str = "rotate.lock"; + +/// The log of an instance started while another one was writing [`LOG_FILE`]. +fn instance_log(dir: &Path, pid: u32) -> PathBuf { + dir.join(format!("photocraft-{pid}.log")) +} + +/// The lock file that marks [`instance_log`] as in use. +fn instance_lock(dir: &Path, pid: u32) -> PathBuf { + dir.join(format!("photocraft-{pid}.lock")) +} + +/// The process id in an [`instance_log`] file name. +fn instance_pid(name: &str) -> Option { + let digits = name.strip_prefix("photocraft-")?.strip_suffix(".log")?; + if digits.is_empty() || !digits.bytes().all(|b| b.is_ascii_digit()) { + return None; + } + digits.parse().ok() +} + +fn open_lock(path: &Path) -> std::io::Result { + OpenOptions::new().read(true).write(true).create(true).truncate(false).open(path) +} + +/// Take `file`'s lock without waiting: `Ok(true)` holds it, `Ok(false)` when another process holds it. +fn try_hold(file: &File) -> std::io::Result { + match file.try_lock() { + Ok(()) => Ok(true), + Err(TryLockError::WouldBlock) => Ok(false), + Err(TryLockError::Error(e)) => Err(e), + } +} + +/// Open a fresh, empty log file in `dir` (created if missing). +/// +/// Normally the previous logs shift up one (`.log` → `.1.log` → … → `..log`, the oldest +/// dropped). While another instance is still writing [`LOG_FILE`] (it holds [`OWNER_LOCK`]), the +/// chain is left alone and this start writes `photocraft-.log` instead: rotating would move +/// that instance's live log away and, a few launches later, delete it (#6432). Such side-by-side +/// logs are pruned to the newest [`KEEP`] once their instances have exited. On a file system +/// without locks every start rotates, as before. +pub fn rotate(dir: &Path) -> std::io::Result { std::fs::create_dir_all(dir)?; + // Waits only for another start's few file operations; without locking support, go on. + let guard = open_lock(&dir.join(ROTATE_LOCK)).ok().filter(|g| g.lock().is_ok()); + let log = rotate_locked(dir); + drop(guard); + log +} + +fn rotate_locked(dir: &Path) -> std::io::Result { + prune_instance_logs(dir); + let owner = open_lock(&dir.join(OWNER_LOCK))?; + let state = try_hold(&owner); + if let Ok(false) = state { + let pid = std::process::id(); + let lock = open_lock(&instance_lock(dir, pid))?; + let held = try_hold(&lock).unwrap_or(false); + let path = instance_log(dir, pid); + let file = File::create(&path)?; + return Ok(LogFile { path, file, owner: held.then_some(lock) }); + } + // This start holds the owner lock (or the file system has no locks). + let held = state.unwrap_or(false); for n in (1..=KEEP).rev() { match std::fs::rename(numbered(dir, n - 1), numbered(dir, n)) { Err(e) if e.kind() != std::io::ErrorKind::NotFound => return Err(e), @@ -128,7 +196,40 @@ pub fn rotate(dir: &Path) -> std::io::Result<(PathBuf, File)> { } let path = numbered(dir, 0); let file = File::create(&path)?; - Ok((path, file)) + Ok(LogFile { path, file, owner: held.then_some(owner) }) +} + +/// Remove the side-by-side logs of instances that have exited, all but the newest [`KEEP`]. +/// A log whose lock file is held belongs to a running instance and is never touched. Failures +/// are ignored: pruning only saves disk space. +fn prune_instance_logs(dir: &Path) { + let Ok(entries) = std::fs::read_dir(dir) else { return }; + let mut finished: Vec<(SystemTime, u32)> = Vec::new(); + for entry in entries.flatten() { + let Some(pid) = entry.file_name().to_str().and_then(instance_pid) else { continue }; + // A missing lock file means nobody holds it; an unknown lock state counts as running. + let running = match OpenOptions::new().read(true).write(true).open(instance_lock(dir, pid)) { + Ok(lock) => !try_hold(&lock).unwrap_or(false), + Err(_) => false, + }; + if !running { + let modified = entry.metadata().and_then(|m| m.modified()).unwrap_or(SystemTime::UNIX_EPOCH); + finished.push((modified, pid)); + } + } + finished.sort_unstable_by(|a, b| b.cmp(a)); + for &(_, pid) in finished.iter().skip(KEEP) { + let _ = std::fs::remove_file(instance_log(dir, pid)); + let _ = std::fs::remove_file(instance_lock(dir, pid)); + } +} + +/// A log file opened by [`rotate`]. +pub struct LogFile { + pub path: PathBuf, + pub file: File, + /// The held lock that marks `path` as in use; `None` where locking isn't supported. + owner: Option, } /// One formatted log line: ` [] : \n`. @@ -205,17 +306,20 @@ pub struct AppLogger { filter: Filter, stderr: bool, sink: Mutex, + /// The lock that marks the attached log file as in use (see [`rotate`]); held until exit. + owner: Mutex>, } impl AppLogger { pub fn new(filter: Filter, stderr: bool) -> AppLogger { - AppLogger { filter, stderr, sink: Mutex::new(Sink::new(MAX_FILE_BYTES)) } + AppLogger { filter, stderr, sink: Mutex::new(Sink::new(MAX_FILE_BYTES)), owner: Mutex::new(None) } } /// Rotate the logs in `dir` and send records to the fresh file; returns its path. pub fn attach_dir(&self, dir: &Path) -> Result { - let (path, file) = rotate(dir).map_err(|e| format!("{}: {e}", dir.display()))?; + let LogFile { path, file, owner } = rotate(dir).map_err(|e| format!("{}: {e}", dir.display()))?; self.sink.lock().unwrap_or_else(PoisonError::into_inner).attach(file); + *self.owner.lock().unwrap_or_else(PoisonError::into_inner) = owner; Ok(path) } } @@ -318,7 +422,7 @@ mod tests { fn rotation_keeps_the_previous_logs_and_starts_an_empty_file() { let dir = temp_dir("rotate"); for run in 1..=4 { - let (path, _file) = rotate(&dir).expect("rotate"); + let LogFile { path, file: _file, owner: _owner } = rotate(&dir).expect("rotate"); assert_eq!(path, dir.join(LOG_FILE)); assert_eq!(std::fs::read_to_string(&path).expect("read"), ""); std::fs::write(&path, format!("run {run}")).expect("write"); @@ -330,6 +434,64 @@ mod tests { let _ = std::fs::remove_dir_all(&dir); } + /// #6432: a second launch moved the running instance's live log to `.1.log`, a third to + /// `.2.log`, and a fourth deleted it. + #[test] + fn a_second_start_leaves_the_running_instance_log_alone() { + let dir = temp_dir("second"); + let mut first = rotate(&dir).expect("first start"); + assert_eq!(first.path, dir.join(LOG_FILE)); + first.file.write_all(b"first, before\n").expect("write"); + for _ in 0..KEEP + 1 { + let second = rotate(&dir).expect("second start"); + assert_eq!(second.path, instance_log(&dir, std::process::id())); + assert_eq!(read(&second.path), ""); + } + first.file.write_all(b"first, after\n").expect("write"); + assert_eq!(read(&dir.join(LOG_FILE)), "first, before\nfirst, after\n"); + assert!(!dir.join("photocraft.1.log").exists(), "the running instance's log was rotated"); + // Once the first instance is gone, the next start rotates as before. + drop(first); + let third = rotate(&dir).expect("third start"); + assert_eq!(third.path, dir.join(LOG_FILE)); + assert_eq!(read(&dir.join("photocraft.1.log")), "first, before\nfirst, after\n"); + drop(third); + let _ = std::fs::remove_dir_all(&dir); + } + + #[test] + fn instance_logs_are_pruned_only_when_no_one_holds_them() { + let dir = temp_dir("prune"); + let first = rotate(&dir).expect("first start"); + // Earlier side-by-side runs that have exited, oldest first; the oldest has no lock file + // (as if its lock file had been removed), the others an unlocked one. + let now = SystemTime::now(); + for (age, pid) in [(400u64, 1u32), (300, 2), (200, 3), (100, 4)] { + let path = instance_log(&dir, pid); + std::fs::write(&path, format!("run {pid}")).expect("write"); + File::options().write(true).open(&path).expect("open").set_modified(now - Duration::from_secs(age)).expect("mtime"); + if pid != 1 { + std::fs::write(instance_lock(&dir, pid), "").expect("lock file"); + } + } + // One older still running: its log is never removed, however old. + let live = instance_log(&dir, 5); + std::fs::write(&live, "live").expect("write"); + File::options().write(true).open(&live).expect("open").set_modified(now - Duration::from_secs(900)).expect("mtime"); + let held = File::create(instance_lock(&dir, 5)).expect("lock file"); + held.try_lock().expect("hold"); + + let second = rotate(&dir).expect("second start"); + assert_eq!(second.path, instance_log(&dir, std::process::id())); + // The newest KEEP finished ones stay, the rest go with their lock files. + assert!(instance_log(&dir, 4).exists() && instance_log(&dir, 3).exists()); + assert!(!instance_log(&dir, 2).exists() && !instance_log(&dir, 1).exists()); + assert!(!instance_lock(&dir, 2).exists()); + assert_eq!(read(&live), "live"); + drop((first, second, held)); + let _ = std::fs::remove_dir_all(&dir); + } + #[test] fn rotation_into_an_unusable_directory_is_an_error_not_a_panic() { let dir = temp_dir("blocked"); @@ -355,7 +517,7 @@ mod tests { #[test] fn lines_logged_before_the_file_exists_are_written_when_it_is_attached() { let dir = temp_dir("pending"); - let (path, file) = rotate(&dir).expect("rotate"); + let LogFile { path, file, .. } = rotate(&dir).expect("rotate"); let mut sink = Sink::new(MAX_FILE_BYTES); sink.write("early 1\n"); sink.write("early 2\n"); @@ -368,7 +530,7 @@ mod tests { #[test] fn the_pending_buffer_is_bounded_and_says_how_much_it_dropped() { let dir = temp_dir("bounded"); - let (path, file) = rotate(&dir).expect("rotate"); + let LogFile { path, file, .. } = rotate(&dir).expect("rotate"); let mut sink = Sink::new(MAX_FILE_BYTES); for i in 0..MAX_PENDING + 5 { sink.write(&format!("line {i}\n")); @@ -384,7 +546,7 @@ mod tests { #[test] fn the_file_stops_at_its_size_cap_with_one_note() { let dir = temp_dir("cap"); - let (path, file) = rotate(&dir).expect("rotate"); + let LogFile { path, file, .. } = rotate(&dir).expect("rotate"); let mut sink = Sink::new(100); sink.attach(file); for i in 0..50 { diff --git a/docs/development.md b/docs/development.md index 035efbbcf0..239f9456bf 100644 --- a/docs/development.md +++ b/docs/development.md @@ -99,7 +99,7 @@ If the device is lost while running (#243), every GPU entry point checks the dev ## Logs -The desktop app writes its `log` records to standard error and to `logs/photocraft.log` in the settings directory (Linux `~/.config/photocraft/logs/`, macOS `~/Library/Application Support/Photocraft/logs/`, Windows `%APPDATA%\Photocraft\logs\`, or under `PHOTOCRAFT_CONFIG_DIR` / the portable data folder). A start launched from a desktop menu or the Dock has no terminal, so this file is what to attach to a bug report: GPU startup fallbacks, a lost device and the crash guard's panic report all land there. Each launch moves the previous log to `photocraft.1.log` (and that one to `photocraft.2.log`), so the log of a run that crashed survives the next start. The file stops growing at 16 MiB. `--version` and command-line errors write no file. +The desktop app writes its `log` records to standard error and to `logs/photocraft.log` in the settings directory (Linux `~/.config/photocraft/logs/`, macOS `~/Library/Application Support/Photocraft/logs/`, Windows `%APPDATA%\Photocraft\logs\`, or under `PHOTOCRAFT_CONFIG_DIR` / the portable data folder). A start launched from a desktop menu or the Dock has no terminal, so this file is what to attach to a bug report: GPU startup fallbacks, a lost device and the crash guard's panic report all land there. Each launch moves the previous log to `photocraft.1.log` (and that one to `photocraft.2.log`), so the log of a run that crashed survives the next start. A launch while another PhotoCraft is still running leaves that instance's log where it is and writes its own `photocraft-.log` next to it (the newest two of those are kept once their instances have exited; the `PhotoCraft , log file ` line at startup names the file in use). The file stops growing at 16 MiB. `--version` and command-line errors write no file. By default PhotoCraft's own crates log at `info` and everything else at `warn`. `RUST_LOG` replaces that with env_logger-style directives, for example `RUST_LOG=debug`, `RUST_LOG=warn,photocraft_gpu=trace` or `RUST_LOG=info,wgpu_core=warn`; a directive ending in `*` covers every target starting with it (`photocraft*=debug`). The logger is `apps/photocraft/src/logging.rs`; the web build logs to the browser console instead. From 482fd899cfd6c6a55fbd68fbb80cee6c2bc8a9cc Mon Sep 17 00:00:00 2001 From: skylar taylor-barrick Date: Sat, 17 Oct 2026 12:02:56 -0600 Subject: [PATCH 83/85] Build: take craft-docpath from the merged craft-libs rev craft-libs#169 is merged, so the fork pin is no longer needed. Pinned to storytold/craft-libs 82a24b006eefa036b43c09a67fb9a3fbd17ffe63 (main after the merge) rather than the older 12281d07, which predates resolve_absolute and the Windows colon refusal. --- Cargo.lock | 2 +- Cargo.toml | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 6c3369fdec..a949941fb3 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1225,7 +1225,7 @@ dependencies = [ [[package]] name = "craft-docpath" version = "0.1.0" -source = "git+https://github.com/aiongg/craft-libs?rev=12281d07fedff5c955bb68344fb287aa5d87971e#12281d07fedff5c955bb68344fb287aa5d87971e" +source = "git+https://github.com/storytold/craft-libs?rev=82a24b006eefa036b43c09a67fb9a3fbd17ffe63#82a24b006eefa036b43c09a67fb9a3fbd17ffe63" [[package]] name = "craft-print" diff --git a/Cargo.toml b/Cargo.toml index 1d65deac3a..5965edc389 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -39,7 +39,7 @@ photocraft-tablet = { path = "crates/tablet" } # external craft-print = { git = "https://github.com/storytold/craft-libs", rev = "3d37da389a3618ab9b76109da11416e9ca4ea719" } -craft-docpath = { git = "https://github.com/aiongg/craft-libs", version = "0.1.0", rev = "12281d07fedff5c955bb68344fb287aa5d87971e" } +craft-docpath = { version = "0.1.0", git = "https://github.com/storytold/craft-libs", rev = "82a24b006eefa036b43c09a67fb9a3fbd17ffe63" } thiserror = "2" serde = { version = "1", features = ["derive", "rc"] } serde_json = "1" From 1d65bd605b25e325a12ab3e4d846d94ba41eedb7 Mon Sep 17 00:00:00 2001 From: Michael Higgins <55243596+Higgs32584@users.noreply.github.com> Date: Sat, 17 Oct 2026 16:20:44 -0400 Subject: [PATCH 84/85] Web: Place Embedded and canvas drops place images (#5749) (#5817) * Web: Place Embedded and canvas drops place images (#5749) File > Place Embedded was disabled in the browser (the engine command reads a path), though the menu path reads the picked file's bytes. Files dropped on the canvas were always opened as new documents. The web shell now tracks the drag position, and drops on the canvas are placed from their bytes. Co-Authored-By: Claude Opus 5.5 * Format (cargo fmt) Co-Authored-By: Claude Opus 5.5 * Narrow to the web canvas-drop half: Place Embedded enablement landed in #5585 Drop the picker test that covered the part #5585 now owns. Co-Authored-By: Claude Opus 5.5 --------- Co-authored-by: Claude Opus 5.5 --- apps/photocraft-web/Cargo.toml | 2 +- apps/photocraft-web/src/web.rs | 74 ++++++++++++++++++++++----- crates/ui-egui/src/file_open.rs | 50 +++++++++++++++--- crates/ui-egui/src/file_open/tests.rs | 29 +++++++++++ crates/ui-egui/src/lib.rs | 18 +++++-- 5 files changed, 149 insertions(+), 24 deletions(-) diff --git a/apps/photocraft-web/Cargo.toml b/apps/photocraft-web/Cargo.toml index 786165b532..4ecad80ae4 100644 --- a/apps/photocraft-web/Cargo.toml +++ b/apps/photocraft-web/Cargo.toml @@ -34,7 +34,7 @@ rfd = { workspace = true } wasm-bindgen = "0.2" wasm-bindgen-futures = "0.4" js-sys = "0.3" -web-sys = { version = "0.3", features = ["Window", "Document", "Element", "HtmlElement", "HtmlCanvasElement", "HtmlAnchorElement", "Blob", "BlobPropertyBag", "Url", "Location", "CssStyleDeclaration", "Storage", "Event", "EventTarget", "MouseEvent", "PointerEvent", "BeforeUnloadEvent", "AbortController", "AbortSignal", "DomException", "Response", "IdbFactory", "IdbOpenDbRequest", "IdbDatabase", "IdbRequest", "IdbTransaction", "IdbTransactionMode", "IdbObjectStore", "IdbCursor", "IdbCursorWithValue", "RequestInit", "Navigator"] } +web-sys = { version = "0.3", features = ["Window", "Document", "Element", "HtmlElement", "HtmlCanvasElement", "DomRect", "HtmlAnchorElement", "Blob", "BlobPropertyBag", "Url", "Location", "CssStyleDeclaration", "Storage", "Event", "EventTarget", "MouseEvent", "PointerEvent", "BeforeUnloadEvent", "AbortController", "AbortSignal", "DomException", "Response", "IdbFactory", "IdbOpenDbRequest", "IdbDatabase", "IdbRequest", "IdbTransaction", "IdbTransactionMode", "IdbObjectStore", "IdbCursor", "IdbCursorWithValue", "RequestInit", "Navigator"] } [dev-dependencies] serde_json = { workspace = true } diff --git a/apps/photocraft-web/src/web.rs b/apps/photocraft-web/src/web.rs index eb350d17eb..6fd3620483 100644 --- a/apps/photocraft-web/src/web.rs +++ b/apps/photocraft-web/src/web.rs @@ -1,6 +1,8 @@ //! The browser shell: web `Services`, drag-and-drop, and the eframe web runner. +use std::cell::Cell; use std::collections::{HashMap, HashSet}; +use std::rc::Rc; use std::sync::atomic::{AtomicBool, Ordering}; use std::sync::{Arc, Mutex}; @@ -79,11 +81,13 @@ pub fn start() { Box::new(move |cc| { PhotocraftApp::setup_context(&cc.egui_ctx, ThemeKind::Pro); let inbox: Inbox = Arc::default(); + let place_inbox: Inbox = Arc::default(); let served: Served = Arc::default(); load_font_manifest(served.clone(), cc.egui_ctx.clone()); let (session, warnings) = crate::preset_bridge::session(presets.clone()); preset_warnings.extend(warnings); - let mut app = PhotocraftApp::new(session, services(inbox.clone())); + let drag = listen_drag(&pen_target); + let mut app = PhotocraftApp::new(session, services(inbox.clone(), place_inbox.clone(), drag_pos_service(pen_target.clone(), drag))); if !preset_warnings.is_empty() { photocraft_ui_egui::notices::post(&mut app, i18n::t("Some brush presets could not be loaded"), preset_warnings, true, None); } @@ -97,7 +101,7 @@ pub fn start() { } let unsaved = Arc::new(AtomicBool::new(false)); guard_unload(unsaved.clone()); - Ok(Box::new(WebShell { app, inbox, unsaved, served, database, presets })) + Ok(Box::new(WebShell { app, inbox, place_inbox, unsaved, served, database, presets })) }), ) .await; @@ -140,6 +144,38 @@ fn listen_pen(target: &web_sys::HtmlCanvasElement, feed: photocraft_ui_egui::sty } } +/// The pointer in a drag over the page, in CSS pixels relative to the viewport (`clientX`, +/// `clientY`): the last `dragover` or `drop` event's. +type DragPos = Rc>>; + +/// Follows files dragged over the canvas. eframe's drop events carry no pointer position and the +/// page gets no pointer events during a drag, so this is where a drop lands: on the document's +/// canvas it places the files, elsewhere it opens them (#5749). +fn listen_drag(target: &web_sys::HtmlCanvasElement) -> DragPos { + use wasm_bindgen::closure::Closure; + let pos = DragPos::default(); + for kind in ["dragover", "drop"] { + let pos = pos.clone(); + // A DragEvent is a MouseEvent: only its client position is read. + let cb = Closure::::new(move |e: web_sys::MouseEvent| pos.set(Some((e.client_x() as f32, e.client_y() as f32)))); + if target.add_event_listener_with_callback(kind, cb.as_ref().unchecked_ref()).is_ok() { + cb.forget(); + } + } + pos +} + +/// `Services::cursor_pos` for the web: the last drag position in egui points, mapped as eframe +/// maps pointer events (relative to the canvas, divided by the zoom factor). +fn drag_pos_service(canvas: web_sys::HtmlCanvasElement, pos: DragPos) -> photocraft_ui_egui::CursorPosFn { + Box::new(move |ctx: &egui::Context| { + let (x, y) = pos.get()?; + let rect = canvas.get_bounding_client_rect(); + let zoom = ctx.zoom_factor(); + (zoom.is_finite() && zoom > 0.0).then(|| egui::pos2((x - rect.left() as f32) / zoom, (y - rect.top() as f32) / zoom)) + }) +} + /// Closing or reloading the tab while a document has unsaved changes asks first, as closing the /// desktop window does: the browser shows its own "Leave site?" prompt (#1380). `unsaved` is /// refreshed every frame by [`WebShell`]. @@ -219,10 +255,13 @@ async fn fetch_bytes(url: &str, integrity: Option<&str>) -> Result, Stri } /// Wraps the app to read dropped files asynchronously (browsers can't read them synchronously, -/// so the app's own drop path can't handle them) and feed them through the inbox. +/// so the app's own drop path can't handle them) and feed them through the inboxes. struct WebShell { app: PhotocraftApp, + /// Dropped files to open as documents. inbox: Inbox, + /// Dropped files to place on the active document's canvas. + place_inbox: Inbox, /// Read by the `beforeunload` listener ([`guard_unload`]). unsaved: Arc, served: Served, @@ -285,17 +324,26 @@ impl WebShell { impl eframe::App for WebShell { fn logic(&mut self, ctx: &egui::Context, frame: &mut eframe::Frame) { let dropped = ctx.input_mut(|i| std::mem::take(&mut i.raw.dropped_files)); - for f in dropped { - let inbox = self.inbox.clone(); + if !dropped.is_empty() { + let names: Vec = + dropped.iter().map(|f| f.path().file_name().map(|n| n.to_string_lossy().to_string()).unwrap_or_else(|| "dropped".into())).collect(); + // Decided now, where the drop landed: on the document's canvas the files are placed + // as layers (as on the desktop), elsewhere opened. + let places = self.app.dropped_on_canvas(ctx, &names); + let files: Vec<_> = dropped.into_iter().zip(names).zip(places).collect(); + let (inbox, place_inbox) = (self.inbox.clone(), self.place_inbox.clone()); let ctx = ctx.clone(); + // One after another, so they open or place in the order they were dropped. wasm_bindgen_futures::spawn_local(async move { - let name = f.path().file_name().map(|n| n.to_string_lossy().to_string()).unwrap_or_else(|| "dropped".into()); - match f.bytes_async().await { - Ok(bytes) => { - inbox.lock().unwrap_or_else(|e| e.into_inner()).push((name, bytes)); - ctx.request_repaint(); + for ((f, name), place) in files { + match f.bytes_async().await { + Ok(bytes) => { + let queue = if place { &place_inbox } else { &inbox }; + queue.lock().unwrap_or_else(|e| e.into_inner()).push((name, bytes)); + ctx.request_repaint(); + } + Err(e) => log::error!("couldn't read dropped file {name}: {e}"), } - Err(e) => log::error!("couldn't read dropped file {name}: {e}"), } }); } @@ -333,7 +381,7 @@ impl eframe::App for WebShell { } } -fn services(inbox: Inbox) -> Services { +fn services(inbox: Inbox, place_inbox: Inbox, cursor_pos: photocraft_ui_egui::CursorPosFn) -> Services { Services { screen_pick: screen_color_service(), import: Some(Box::new(|name: &str, bytes: &[u8], max_svg_group_depth: usize| { @@ -384,6 +432,8 @@ fn services(inbox: Inbox) -> Services { photocraft_codecs::encode(&img, photocraft_codecs::Format::Png, &EncodeOptions::default()).map_err(|e| e.to_string()) })), inbox: Some(inbox), + place_inbox: Some(place_inbox), + cursor_pos: Some(cursor_pos), // Preferences live in the browser's localStorage. load_prefs: Some(Box::new(|| local_storage()?.get_item(PREFS_KEY).ok().flatten())), save_prefs: Some(Box::new(|text: &str| local_storage().ok_or("no localStorage")?.set_item(PREFS_KEY, text).map_err(|e| format!("{e:?}")))), diff --git a/crates/ui-egui/src/file_open.rs b/crates/ui-egui/src/file_open.rs index 53e908bec9..1cf61e98d3 100644 --- a/crates/ui-egui/src/file_open.rs +++ b/crates/ui-egui/src/file_open.rs @@ -33,6 +33,15 @@ pub enum DropTarget { Elsewhere, } +/// A file dropped on the canvas, waiting its turn to be placed. +pub(crate) enum DropPlace { + /// Dropped onto the window (desktop): read from its path, or through egui. + File(egui::DroppedFileHandle), + /// Read by the shell itself (the browser reads dropped files asynchronously): its name and + /// contents, delivered through [`Services::place_inbox`](crate::Services::place_inbox). + Bytes(String, Vec), +} + /// `warnings` from an import, plus one for each font family the active document's type uses that /// isn't installed (#4559): Photoshop warns about missing fonts as a file opens, and the CLI and /// automation already report them; the type would otherwise silently render in a fallback face. @@ -211,7 +220,7 @@ impl PhotocraftApp { for f in files { let name = dropped_name(&f); if target == DropTarget::Canvas && !crate::preset_files_ui::is_preset_file(&name) { - self.drop_places.push_back(f); + self.drop_places.push_back(DropPlace::File(f)); continue; } match self.open_dropped_file(&f, &name, slot) { @@ -222,6 +231,24 @@ impl PhotocraftApp { } } + /// Which of the files called `names`, dropped onto the window just now, are placed on the + /// active document's canvas rather than opened, for a shell that reads dropped files itself + /// (the browser reads them asynchronously, so the web shell can't hand them to + /// [`open_dropped`](Self::open_dropped)). It delivers those through + /// [`Services::place_inbox`](crate::Services::place_inbox) and the rest through + /// [`Services::inbox`](crate::Services::inbox). The pointer comes from + /// [`Services::cursor_pos`](crate::Services::cursor_pos). As with a desktop canvas drop, a Free + /// Transform still open is applied first (#4833). + pub fn dropped_on_canvas(&mut self, ctx: &egui::Context, names: &[String]) -> Vec { + let at = self.services.cursor_pos.as_mut().and_then(|f| f(ctx)); + let canvas = self.drop_target(ctx, at) == DropTarget::Canvas; + let places: Vec = names.iter().map(|n| canvas && !crate::preset_files_ui::is_preset_file(n)).collect(); + if places.contains(&true) && self.ui.transform.is_some() { + crate::transform_tool::commit(self); + } + places + } + /// Where a drop at `at` lands. Over a window or dialog it's neither the canvas nor the tabs. pub(crate) fn drop_target(&self, ctx: &egui::Context, at: Option) -> DropTarget { let Some(p) = at.filter(|p| ctx.layer_id_at(*p).is_none_or(|l| l.order == egui::Order::Background)) else { @@ -259,11 +286,20 @@ impl PhotocraftApp { /// transform is in progress (so several files are placed and transformed one after another). pub(crate) fn place_next_dropped(&mut self, ctx: &egui::Context) { while self.ui.transform.is_none() { - let Some(f) = self.drop_places.pop_front() else { return }; - let placed = if f.path().is_absolute() { - self.run("file.placeEmbedded", serde_json::json!({"path": f.path().to_string_lossy()})) - } else { - crate::read_dropped(&*f).and_then(|bytes| self.place_bytes(&dropped_name(&f), bytes, None)) + let Some(next) = self.drop_places.pop_front() else { return }; + let (name, placed) = match next { + DropPlace::File(f) if f.path().is_absolute() => { + (dropped_name(&f), self.run("file.placeEmbedded", serde_json::json!({"path": f.path().to_string_lossy()}))) + } + DropPlace::File(f) => { + let name = dropped_name(&f); + let placed = crate::read_dropped(&*f).and_then(|bytes| self.place_bytes(&name, bytes, None)); + (name, placed) + } + DropPlace::Bytes(name, bytes) => { + let placed = self.place_bytes(&name, bytes, None); + (name, placed) + } }; match placed { Ok(_) => { @@ -271,7 +307,7 @@ impl PhotocraftApp { self.ui.status = e; } } - Err(e) => self.failed(crate::i18n::fmt(tl!("Couldn't place {name}: {error}"), &[("name", &dropped_name(&f)), ("error", &e)])), + Err(e) => self.failed(crate::i18n::fmt(tl!("Couldn't place {name}: {error}"), &[("name", &name), ("error", &e)])), } } } diff --git a/crates/ui-egui/src/file_open/tests.rs b/crates/ui-egui/src/file_open/tests.rs index b43a10a642..00db3ef96e 100644 --- a/crates/ui-egui/src/file_open/tests.rs +++ b/crates/ui-egui/src/file_open/tests.rs @@ -173,6 +173,35 @@ fn the_web_opens_every_selected_file() { assert!(app.ui.notices.iter().any(|n| n.error && n.title.contains("broken.png")), "{:?}", app.ui.notices); } +/// #5749: the web shell reads dropped files itself, so it asks where they landed: image files +/// dropped on the canvas are placed from their bytes, presets and drops elsewhere are opened. +#[test] +fn web_drops_onto_the_canvas_are_placed_from_their_bytes() { + let ctx = egui::Context::default(); + let mut app = app_with_canvas(); + let names = vec!["photo.png".to_string(), "brushes.abr".to_string()]; + // No pointer known: nothing is placed. + assert_eq!(app.dropped_on_canvas(&ctx, &names), [false, false]); + let at = Rc::new(std::cell::Cell::new(egui::pos2(400.0, 300.0))); + let reported = at.clone(); + app.services.cursor_pos = Some(Box::new(move |_: &egui::Context| Some(reported.get()))); + assert_eq!(app.dropped_on_canvas(&ctx, &names), [true, false]); + at.set(egui::pos2(20.0, 20.0)); + assert_eq!(app.dropped_on_canvas(&ctx, &names), [false, false], "off the canvas: opened"); + let layers = layer_count(&app); + app.drop_places.push_back(crate::file_open::DropPlace::Bytes("photo.png".into(), png_bytes(4, 3))); + app.drop_places.push_back(crate::file_open::DropPlace::Bytes("bad.png".into(), b"bad".to_vec())); + app.place_next_dropped(&ctx); + assert_eq!(layer_count(&app), layers + 1); + assert_eq!(app.ui.transform.as_ref().and_then(|t| t.made), Some(crate::state::MadeLayer::Place)); + crate::transform_tool::commit(&mut app); + // The undecodable one is reported, not placed. + app.place_next_dropped(&ctx); + assert_eq!(layer_count(&app), layers + 1); + assert!(app.ui.notices.iter().any(|n| n.error && n.title.contains("bad.png")), "{:?}", app.ui.notices); + assert!(app.drop_places.is_empty()); +} + #[test] fn pcraft_documents_save_in_place_but_flat_files_ask() { let (mut app, written) = app_with(vec![None]); diff --git a/crates/ui-egui/src/lib.rs b/crates/ui-egui/src/lib.rs index 496b208288..5adfd78860 100644 --- a/crates/ui-egui/src/lib.rs +++ b/crates/ui-egui/src/lib.rs @@ -485,8 +485,13 @@ pub struct Services { /// Preferences ▸ General ▸ Beep When Done: the platform's default beep, played when a /// background job finishes successfully. `None` in the web build (and in tests). pub beep: Option, - /// Files delivered asynchronously (web drag-and-drop): drained every frame. + /// Files delivered asynchronously (web drag-and-drop): drained every frame and opened as + /// documents. pub inbox: Option, + /// Files delivered asynchronously for a drop onto the active document's canvas (web + /// drag-and-drop, routed by [`PhotocraftApp::dropped_on_canvas`]): placed as layers like a + /// desktop canvas drop (Place Embedded, each in Free Transform). + pub place_inbox: Option, /// OS clipboard images: copies go out, screenshots and images from other apps come in. pub clipboard_set_image: Option, pub clipboard_get_image: Option, @@ -513,8 +518,9 @@ pub struct Services { pub append_text: Option, /// OS requests (macOS open-documents / quit Apple events), polled every frame. pub os_events: Option, - /// The pointer position read from the OS (desktop): winit 0.30's file drops carry none, and - /// the window gets no pointer events during an OS drag (see `file_open::DropTarget`). + /// The pointer position read from the OS (desktop) or from the page's drag events (web): + /// file drops carry none, and the window gets no pointer events during an OS drag (see + /// `file_open::DropTarget`). pub cursor_pos: Option, /// Caps Lock toggled on (desktop; `None` on Wayland and the web). Read once per frame, so /// the canvas can show the precise crosshair for painting tools, whatever the preference. @@ -672,7 +678,7 @@ pub struct PhotocraftApp { /// The document tab strip last frame: a drop there opens the file at the slot under it. pub(crate) tab_strip: Option, /// Files dropped on the canvas still to place, one Free Transform at a time. - pub(crate) drop_places: std::collections::VecDeque, + pub(crate) drop_places: std::collections::VecDeque, /// The document each of `ui.views` belongs to, as of the last [`Self::sync_views`]. view_docs: Vec, pub fps: f32, @@ -1917,6 +1923,10 @@ impl eframe::App for PhotocraftApp { self.open_failed(&name, &e); } } + // Web drops onto the canvas, read since the last frame: placed below, one at a time. + let to_place: Vec<(String, Vec)> = + self.services.place_inbox.as_ref().map(|q| std::mem::take(&mut *q.lock().unwrap_or_else(|e| e.into_inner()))).unwrap_or_default(); + self.drop_places.extend(to_place.into_iter().map(|(name, bytes)| file_open::DropPlace::Bytes(name, bytes))); // Finder double-click / Open With / Dock drops (macOS open-documents events). self.drain_os_events(ctx); // While files are dragged over the window it gets no pointer events: keep frames coming so From 235e7578f397d5fb3962d0c72bb0d22d7a1ab7c8 Mon Sep 17 00:00:00 2001 From: Diego Chagas Date: Sat, 17 Oct 2026 18:55:54 -0300 Subject: [PATCH 85/85] =?UTF-8?q?Print=20through=20CUPS=20over=20IPP=20and?= =?UTF-8?q?=20the=20print=20portal,=20so=20File=20=E2=80=BA=20Print=20work?= =?UTF-8?q?s=20in=20the=20Flatpak=20(#2728)=20(#3017)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Main prints through craft-print, which runs `lp`; a Flatpak has no `lp`, so File › Print fails there. This keeps craft-print as the route wherever `lp` is on the path (and on Windows) and adds two more behind it: - `photocraft-ipp`, a pure-Rust IPP client (CUPS-Get-Printers/Classes/ Default, Print-Job over HTTP/1.1) that talks to CUPS over its socket or TCP; the Flatpak manifests grant --socket=cups for it. A `printServer` named in the request always takes this route. `file.print.printers` lists the queues. - The XDG Print portal as the app's spooler (`Session::print_spooler`), for a sandbox without the CUPS socket or a system without a printer: the system print dialog, seeded with copies, paper and orientation. PHOTOCRAFT_PRINT_PORTAL=1 prefers it, =0 removes it. A route that exists but fails (unknown printer, refused job) is an error; only a missing route moves on. Results gain `backend` (lp, windows, ipp, portal). docs/printing.md describes the routes. --- Cargo.lock | 6 + Cargo.toml | 1 + README.md | 2 + apps/photocraft/Cargo.toml | 2 + apps/photocraft/src/main.rs | 13 +- apps/photocraft/src/print_portal.rs | 239 +++++++ crates/engine/Cargo.toml | 4 + crates/engine/src/lib.rs | 3 + crates/engine/src/print_cmds.rs | 235 ++++++- crates/engine/src/print_cmds/tests.rs | 291 ++++++++- crates/ipp/Cargo.toml | 12 + crates/ipp/README.md | 22 + crates/ipp/src/lib.rs | 600 ++++++++++++++++++ crates/ipp/src/tests.rs | 395 ++++++++++++ docs/development.md | 1 + docs/printing.md | 67 ++ docs/scorecard.md | 4 +- .../ai.storyteller.photocraft.bundle.yml | 3 + .../flatpak/ai.storyteller.photocraft.yml | 3 + xtask/src/layers.rs | 2 + 20 files changed, 1884 insertions(+), 21 deletions(-) create mode 100644 apps/photocraft/src/print_portal.rs create mode 100644 crates/ipp/Cargo.toml create mode 100644 crates/ipp/README.md create mode 100644 crates/ipp/src/lib.rs create mode 100644 crates/ipp/src/tests.rs create mode 100644 docs/printing.md diff --git a/Cargo.lock b/Cargo.lock index 43a4a44f65..fefc7e5771 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -4651,6 +4651,7 @@ dependencies = [ "rfd", "ron", "serde_json", + "tempfile", "winit", "winresource", "winsafe", @@ -4800,6 +4801,7 @@ dependencies = [ "photocraft-format", "photocraft-geom", "photocraft-io", + "photocraft-ipp", "photocraft-ops", "photocraft-paint", "photocraft-plugins", @@ -4899,6 +4901,10 @@ dependencies = [ "thiserror 2.0.21", ] +[[package]] +name = "photocraft-ipp" +version = "0.9.0" + [[package]] name = "photocraft-ops" version = "0.9.0" diff --git a/Cargo.toml b/Cargo.toml index 4a9b20133c..25a7a101c3 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -36,6 +36,7 @@ photocraft-engine = { path = "crates/engine" } photocraft-ui-egui = { path = "crates/ui-egui" } photocraft-testkit = { path = "crates/testkit" } photocraft-tablet = { path = "crates/tablet" } +photocraft-ipp = { path = "crates/ipp" } # external craft-print = { git = "https://github.com/storytold/craft-libs", rev = "3d37da389a3618ab9b76109da11416e9ca4ea719" } diff --git a/README.md b/README.md index 973fb5330d..12e2ef4b12 100644 --- a/README.md +++ b/README.md @@ -275,6 +275,8 @@ flatpak install --user photocraft--linux-x86_64.flatpak # or -linux-a flatpak run ai.storyteller.photocraft ``` +The Flatpak prints through the host's CUPS socket (File › Print talks IPP to it directly, no `lp` needed); without CUPS, or without a printer, File › Print opens the system print dialog through the print portal. See [printing](docs/printing.md). + The AppImage needs no install: the first run registers its launcher icon and menu entry in `~/.local/share` so the dock shows PhotoCraft's icon on Wayland. Set `PHOTOCRAFT_NO_DESKTOP_INTEGRATION=1` to skip that, and see [`docs/releasing.md`](docs/releasing.md) › Linux to undo it. On a Wayland session, files dropped on the window don't open yet: winit 0.30, the windowing library under egui, has no Wayland drag and drop (#386). Use File › Open, or copy the image in your file manager and paste it with Ctrl+V. To drag and drop, start PhotoCraft under XWayland: `WAYLAND_DISPLAY= photocraft`, `WAYLAND_DISPLAY= ./photocraft--linux-x86_64.AppImage`, or `flatpak run --nosocket=wayland --socket=x11 ai.storyteller.photocraft`. On Wayland, PhotoCraft shows the command for your install in a notice until you dismiss it. Preferences › Performance › Linux display server = X11 does the same on every launch. With a fractional display scale (125%, 150%, 175%) an XWayland window is drawn at a lower resolution and enlarged by the compositor, so the interface is blurred there; native Wayland stays sharp (#1072). diff --git a/apps/photocraft/Cargo.toml b/apps/photocraft/Cargo.toml index 8b7c832bb2..505947e222 100644 --- a/apps/photocraft/Cargo.toml +++ b/apps/photocraft/Cargo.toml @@ -65,6 +65,8 @@ dispatch2 = { version = "=0.3.1", default-features = false, features = ["std"] } # Drop position on X11 (`cursor.rs`): pure-Rust X client, already in Cargo.lock; pinned exactly. [target.'cfg(target_os = "linux")'.dependencies] photocraft-cms = { workspace = true } +# The print portal's PDF: a private (0600) temporary file, removed when the dialog is done. +tempfile = "3" x11rb = { version = "=0.13.2", default-features = false, features = ["image", "randr", "xkb"] } # Clipboard images in types other than PNG on Wayland (`services.rs`, #2546): arboard asks for # image/png only. Already in Cargo.lock (arboard's wayland-data-control); pinned exactly. diff --git a/apps/photocraft/src/main.rs b/apps/photocraft/src/main.rs index 721bbc7349..05355e55bf 100644 --- a/apps/photocraft/src/main.rs +++ b/apps/photocraft/src/main.rs @@ -63,6 +63,9 @@ mod photoshop_settings; #[cfg(target_os = "linux")] mod portal_save; mod presentation; +// The print portal needs the session bus (zbus, Linux only); its pure helpers are tested everywhere. +#[cfg(any(target_os = "linux", test))] +mod print_portal; mod screen_color; mod services; // macOS gets one instance from LaunchServices (open-documents Apple events). @@ -565,7 +568,15 @@ fn main() -> eframe::Result { services::xwayland_command(var("FLATPAK_ID").as_deref(), var("APPIMAGE").as_deref(), std::env::var_os("DISPLAY").is_some()); } } - let mut app = PhotocraftApp::new(Session::new(), services); + #[cfg(target_os = "linux")] + let session = { + let mut session = Session::new(); + session.print_spooler = print_portal::spooler(); + session + }; + #[cfg(not(target_os = "linux"))] + let session = Session::new(); + let mut app = PhotocraftApp::new(session, services); app.min_frame_ms = uncapped.then_some(1000.0 / 240.0); app.integrated_titlebar = cfg!(target_os = "macos"); app.custom_titlebar = custom_titlebar; diff --git a/apps/photocraft/src/print_portal.rs b/apps/photocraft/src/print_portal.rs new file mode 100644 index 0000000000..0eef02a01c --- /dev/null +++ b/apps/photocraft/src/print_portal.rs @@ -0,0 +1,239 @@ +//! Printing through the XDG Print portal (`org.freedesktop.portal.Print`): the system print +//! dialog, which a sandboxed app can reach without a CUPS socket. The engine prints over IPP +//! when it can reach a print server (`photocraft-ipp`); this is its fallback when it cannot, and +//! the only route with `PHOTOCRAFT_PRINT_PORTAL=1` (`0` disables it). `PreparePrint` shows the +//! dialog seeded with the job's copies and paper; `Print` hands it the PDF. Both answer through a +//! `Request` object's `Response` signal, which this module waits for on its own thread so the +//! window keeps painting while the dialog is open; the outcome goes to the log. +//! https://flatpak.github.io/xdg-desktop-portal/docs/doc-org.freedesktop.portal.Print.html + +#[cfg(target_os = "linux")] +use photocraft_engine::print_cmds::{PrintJob, Spooler}; + +/// The spooler to install on the session, unless `PHOTOCRAFT_PRINT_PORTAL=0`. The portal itself +/// is looked up per job, so a missing one costs nothing at start-up. +#[cfg(target_os = "linux")] +pub fn spooler() -> Option { + let setting = std::env::var("PHOTOCRAFT_PRINT_PORTAL").ok(); + if setting.as_deref() == Some("0") { + return None; + } + Some(Spooler { name: "portal", preferred: setting.as_deref() == Some("1"), run: std::sync::Arc::new(print) }) +} + +/// Opens the system print dialog for the job and returns as soon as it is up. +#[cfg(target_os = "linux")] +fn print(job: &PrintJob) -> Result { + let conn = zbus::blocking::connection::Builder::session() + .and_then(|b| b.method_timeout(std::time::Duration::from_secs(5)).build()) + .map_err(|e| format!("no session bus for the print portal ({e}); print to PDF instead"))?; + let proxy = portal(&conn)?; + let version: u32 = proxy.get_property("version").map_err(|e| format!("no print portal on this desktop ({e}); print to PDF instead"))?; + // The document goes to a private temporary file (0600, created atomically by `tempfile`), + // not a guessable name in a shared /tmp. The guard owns the file: it lives on the portal + // thread until the dialog is done, and a failure to start that thread drops it here. + let mut file = tempfile::Builder::new().prefix("photocraft-print-").suffix(".pdf").tempfile().map_err(|e| format!("cannot create the print file: {e}"))?; + std::io::Write::write_all(&mut file, &job.pdf).map_err(|e| format!("cannot write the print file: {e}"))?; + let (title, settings, page_setup) = (job.title.clone(), settings(job), page_setup(job)); + std::thread::Builder::new() + .name("print-portal".into()) + .spawn(move || { + let outcome = dialog(&conn, &title, &settings, &page_setup, file.path()); + drop(file); + match outcome { + Ok(what) => log::info!("print portal (v{version}): {what}"), + Err(e) => log::warn!("print portal (v{version}): {e}"), + } + }) + .map_err(|e| format!("cannot start the print thread: {e}"))?; + Ok("the system print dialog".into()) +} + +#[cfg(target_os = "linux")] +fn portal(conn: &zbus::blocking::Connection) -> Result, String> { + zbus::blocking::Proxy::new(conn, "org.freedesktop.portal.Desktop", "/org/freedesktop/portal/desktop", "org.freedesktop.portal.Print") + .map_err(|e| e.to_string()) +} + +/// `PreparePrint` then `Print`, each waited for on its request's `Response` signal. +#[cfg(target_os = "linux")] +fn dialog( + conn: &zbus::blocking::Connection, + title: &str, + settings: &[(&'static str, Seed)], + page_setup: &[(&'static str, Seed)], + pdf: &std::path::Path, +) -> Result { + use std::collections::HashMap; + use zbus::zvariant::{OwnedObjectPath, OwnedValue, Value}; + type Dict<'a> = HashMap<&'a str, Value<'a>>; + fn as_dict(pairs: &[(&'static str, Seed)]) -> Dict<'static> { + pairs + .iter() + .map(|(k, v)| { + let value = match v { + Seed::Text(t) => Value::from(t.clone()), + Seed::Mm(mm) => Value::from(*mm), + }; + (*k, value) + }) + .collect() + } + let proxy = portal(conn)?; + let sender = conn.unique_name().map(|n| n.as_str().to_string()).ok_or("the bus connection has no name")?; + let wait = |token: &str| -> Result, String> { + let path = request_path(&sender, token); + let request = zbus::blocking::Proxy::new(conn, "org.freedesktop.portal.Desktop", path, "org.freedesktop.portal.Request").map_err(|e| e.to_string())?; + request.receive_signal("Response").map_err(|e| e.to_string()) + }; + let response = |mut signals: zbus::blocking::proxy::SignalIterator<'static>| -> Result, String> { + let message = signals.next().ok_or("the print portal went away")?; + let (code, results): (u32, HashMap) = message.body().deserialize().map_err(|e| format!("odd portal reply: {e}"))?; + match code { + 0 => Ok(results), + 1 => Err("cancelled in the print dialog".into()), + _ => Err("the print dialog failed".into()), + } + }; + + let token = format!("photocraft_prepare_{}", nonce()); + let signals = wait(&token)?; + let mut options: Dict<'_> = HashMap::new(); + options.insert("handle_token", Value::from(token.as_str())); + let _: OwnedObjectPath = + proxy.call("PreparePrint", &("", title, as_dict(settings), as_dict(page_setup), options)).map_err(|e| format!("PreparePrint failed: {e}"))?; + let results = response(signals)?; + let prepared = results.get("token").and_then(|v| u32::try_from(v.clone()).ok()).ok_or("the print dialog gave no token")?; + + let file = std::fs::File::open(pdf).map_err(|e| format!("cannot reopen the print file: {e}"))?; + let token = format!("photocraft_print_{}", nonce()); + let signals = wait(&token)?; + let mut options: Dict<'_> = HashMap::new(); + options.insert("handle_token", Value::from(token.as_str())); + options.insert("token", Value::from(prepared)); + let fd = zbus::zvariant::Fd::from(std::os::fd::AsFd::as_fd(&file)); + let _: OwnedObjectPath = proxy.call("Print", &("", title, fd, options)).map_err(|e| format!("Print failed: {e}"))?; + response(signals)?; + Ok("printed through the system print dialog".into()) +} + +#[cfg(target_os = "linux")] +fn nonce() -> u64 { + use std::sync::atomic::{AtomicU64, Ordering}; + static N: AtomicU64 = AtomicU64::new(0); + let t = std::time::SystemTime::now().duration_since(std::time::UNIX_EPOCH).map(|d| d.as_nanos() as u64).unwrap_or(0); + t ^ N.fetch_add(1, Ordering::Relaxed) +} + +/// The request object the portal answers on: the sender's unique name without its colon and +/// with dots as underscores, then the handle token. +fn request_path(unique_name: &str, token: &str) -> String { + format!("/org/freedesktop/portal/desktop/request/{}/{token}", unique_name.trim_start_matches(':').replace('.', "_")) +} + +/// A value seeded into the dialog: GtkPrintSettings are strings; a GtkPageSetup's sizes are +/// doubles in millimetres (`gtk_paper_size_new_from_gvariant` ignores anything else). +#[derive(Debug, Clone, PartialEq)] +#[cfg_attr(not(target_os = "linux"), allow(dead_code))] +enum Seed { + Text(String), + Mm(f64), +} + +fn text(s: &str) -> Seed { + Seed::Text(s.to_string()) +} + +/// The standard paper sizes, as (PWG name, PPD name, width mm, height mm), portrait. GTK's +/// print settings take the PWG name; its page setup finds the size by the PPD name. +const PAPERS: [(&str, &str, f64, f64); 8] = [ + ("na_letter", "Letter", 215.9, 279.4), + ("na_legal", "Legal", 215.9, 355.6), + ("na_ledger", "Ledger", 279.4, 431.8), + ("iso_a3", "A3", 297.0, 420.0), + ("iso_a4", "A4", 210.0, 297.0), + ("iso_a5", "A5", 148.0, 210.0), + ("na_index-4x6", "4x6", 101.6, 152.4), + ("na_5x7", "5x7", 127.0, 177.8), +]; + +const MM_PER_PT: f64 = 25.4 / 72.0; + +type Paper = (&'static str, &'static str, f64, f64); + +/// The job's paper: its row in `PAPERS` when it is a standard size, and the portrait size in +/// millimetres. +fn paper(paper_pt: (f64, f64)) -> (Option<&'static Paper>, f64, f64) { + let (w, h) = (paper_pt.0.min(paper_pt.1) * MM_PER_PT, paper_pt.0.max(paper_pt.1) * MM_PER_PT); + let row = PAPERS.iter().find(|(_, _, pw, ph)| (pw - w).abs() < 1.0 && (ph - h).abs() < 1.0); + (row, w, h) +} + +fn orientation(landscape: bool) -> Seed { + text(if landscape { "landscape" } else { "portrait" }) +} + +/// `PreparePrint`'s settings (GtkPrintSettings keys, string values). +#[cfg_attr(not(target_os = "linux"), allow(dead_code))] +fn settings_for(copies: u32, paper_pt: (f64, f64), landscape: bool) -> Vec<(&'static str, Seed)> { + let (row, w, h) = paper(paper_pt); + let mut v = vec![("n-copies", text(&copies.to_string())), ("orientation", orientation(landscape))]; + match row { + Some((pwg, _, _, _)) => v.push(("paper-format", text(pwg))), + None => v.extend([("paper-width", text(&format!("{w:.2}"))), ("paper-height", text(&format!("{h:.2}")))]), + } + v +} + +/// `PreparePrint`'s page setup (GtkPageSetup keys): the size in millimetres, found by its PPD +/// name when it has one, else a custom size named by its dimensions. +#[cfg_attr(not(target_os = "linux"), allow(dead_code))] +fn page_setup_for(paper_pt: (f64, f64), landscape: bool) -> Vec<(&'static str, Seed)> { + let (row, w, h) = paper(paper_pt); + let mut v = vec![("Orientation", orientation(landscape)), ("Width", Seed::Mm(w)), ("Height", Seed::Mm(h))]; + match row { + Some((_, ppd, _, _)) => v.push(("PPDName", text(ppd))), + None => v.push(("Name", text(&format!("custom_{w:.0}x{h:.0}mm")))), + } + v +} + +#[cfg(target_os = "linux")] +fn settings(job: &PrintJob) -> Vec<(&'static str, Seed)> { + settings_for(job.copies, job.paper, job.landscape) +} + +#[cfg(target_os = "linux")] +fn page_setup(job: &PrintJob) -> Vec<(&'static str, Seed)> { + page_setup_for(job.paper, job.landscape) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn request_path_follows_the_portal_rule() { + assert_eq!(request_path(":1.42", "photocraft_prepare_7"), "/org/freedesktop/portal/desktop/request/1_42/photocraft_prepare_7"); + } + + #[test] + fn standard_papers_get_their_pwg_and_ppd_names_and_custom_ones_their_size() { + // A4 landscape as the engine lays it out: width > height, still A4. + let s = settings_for(2, (841.89, 595.28), true); + assert_eq!(s, vec![("n-copies", text("2")), ("orientation", text("landscape")), ("paper-format", text("iso_a4"))]); + let p = page_setup_for((841.89, 595.28), true); + assert_eq!(p[0], ("Orientation", text("landscape"))); + let Seed::Mm(w) = p[1].1 else { panic!("width is millimetres") }; + let Seed::Mm(h) = p[2].1 else { panic!("height is millimetres") }; + assert!((w - 210.0).abs() < 0.01 && (h - 297.0).abs() < 0.01, "{w} x {h}"); + assert_eq!(p[3], ("PPDName", text("A4"))); + assert_eq!(settings_for(1, (612.0, 792.0), false)[2], ("paper-format", text("na_letter"))); + assert_eq!(page_setup_for((612.0, 792.0), false)[3], ("PPDName", text("Letter"))); + assert_eq!(settings_for(1, (288.0, 432.0), false)[2], ("paper-format", text("na_index-4x6"))); + // 100 x 200 mm is nobody's standard. + let custom = settings_for(1, (283.46, 566.93), false); + assert_eq!(&custom[2..], &[("paper-width", text("100.00")), ("paper-height", text("200.00"))]); + assert_eq!(page_setup_for((283.46, 566.93), false)[3], ("Name", text("custom_100x200mm"))); + } +} diff --git a/crates/engine/Cargo.toml b/crates/engine/Cargo.toml index 741d927a3f..65dd1e16e0 100644 --- a/crates/engine/Cargo.toml +++ b/crates/engine/Cargo.toml @@ -43,6 +43,10 @@ roxmltree = "0.20" [target.'cfg(not(target_arch = "wasm32"))'.dependencies] tempfile = "3" +# Printing on Unix: an IPP client for CUPS (pure Rust); the web build cannot print. +[target.'cfg(all(not(target_arch = "wasm32"), not(target_os = "windows")))'.dependencies] +photocraft-ipp = { workspace = true } + [dev-dependencies] photocraft-affinity = { workspace = true, features = ["synth"] } # Synthetic camera raw files for the open tests. diff --git a/crates/engine/src/lib.rs b/crates/engine/src/lib.rs index e6486c2a1b..b076769040 100644 --- a/crates/engine/src/lib.rs +++ b/crates/engine/src/lib.rs @@ -486,6 +486,9 @@ pub struct Session { /// the same check a top-level request sees. `None` runs everything, which is what a local /// UI and `photocraft-cli run` do. pub authorize: Option Result<()>>, + /// A further route for File › Print when neither `lp` nor a CUPS server over IPP is + /// reachable (the XDG Print portal in a Flatpak); see `print_cmds::Spooler`. + pub print_spooler: Option, /// Background jobs (see [`jobs`]). jobs: jobs::Jobs, /// Color Sampler points dropped by the last canvas-size change (crop, Canvas Size, Image Size, diff --git a/crates/engine/src/print_cmds.rs b/crates/engine/src/print_cmds.rs index de16a94ac4..ed0f2cefce 100644 --- a/crates/engine/src/print_cmds.rs +++ b/crates/engine/src/print_cmds.rs @@ -8,6 +8,12 @@ //! `"dryRun": true` reports the backend command without spooling; a PDF is kept only at an //! explicit output path. Shared spooler transport files are private and automatically removed. //! +//! Where `lp` is not on the path (a Flatpak has none), the job goes to CUPS over IPP in-process +//! (`photocraft-ipp`, through the sandbox's `--socket=cups`), and when no print server or no +//! printer is reachable, to the app's spooler, the XDG Print portal (`Session::print_spooler`; +//! `PHOTOCRAFT_PRINT_PORTAL=1` prefers it, `=0` removes it). `"printServer"` names a server for +//! one job and always takes the IPP route; `file.printers` lists the queues (#2728). +//! //! Paths to Illustrator writes the document's paths as an Adobe Illustrator 3 compatible //! PostScript file (`m`/`L`/`C` path construction, `n`/`N` unpainted closed/open paths, `*u`/`*U` //! compound paths), following the public PostScript Language Reference and the AI3 format notes. @@ -31,6 +37,191 @@ fn other(e: impl std::fmt::Display) -> EngineError { EngineError::Other(e.to_string()) } +// ---------- Spooling ---------- + +/// A rendered page on its way to the printer: what the IPP route and the app's spooler need. +#[derive(Debug, Clone)] +pub struct PrintJob { + pub pdf: Vec, + /// The printer asked for; `None` means the default. + pub printer: Option, + pub copies: u32, + /// The queue entry's name: the document's. + pub title: String, + /// A print server named in the request (`printServer`), else the system's. + pub server: Option, + /// The page size in points, as laid out (landscape already applied). + pub paper: (f64, f64), + pub landscape: bool, +} + +/// A spooler's work: the job in, where it went out. +pub type SpoolFn = std::sync::Arc std::result::Result + Send + Sync>; + +/// A further route to the printer, installed by the app on `Session::print_spooler`: the XDG +/// Print portal, which shows the system print dialog from inside a sandbox. It takes the jobs +/// no other route can (no print server reachable, no printer set up), or every job when +/// `preferred`. +#[derive(Clone)] +pub struct Spooler { + /// Reported as the job's `backend`. + pub name: &'static str, + pub preferred: bool, + /// Returns where the job went ("the system print dialog"). + pub run: SpoolFn, +} + +/// Why the IPP route could not spool: no route at all (the app's spooler may have one), or a +/// failure on a route that exists (a wrong printer name, a rejected job). +enum SpoolError { + NoRoute(String), + Failed(String), +} + +/// Sends the job and says where it went and through what. A named print server takes the IPP +/// route. Otherwise: the app's spooler when it is preferred; else `craft-print` (`lp`, or the +/// Windows spooler) when it is there; else CUPS over IPP; and the app's spooler when that had +/// no route either. A route that exists but fails (an unknown printer, a refused job) is an +/// error, never a fall-through. +fn spool_job(spooler: Option<&Spooler>, job: &PrintJob, lp: &craft_print::Job) -> Result<(String, String)> { + let app = |sp: &Spooler| (sp.run)(job).map(|m| (m, sp.name.to_string())).map_err(other); + if job.server.is_none() { + if let Some(sp) = spooler.filter(|sp| sp.preferred) { + return app(sp); + } + if let Some(name) = system_spooler() { + return craft_print::submit(&job.pdf, lp).map(|m| (m, name.to_string())).map_err(other); + } + } + match cups_spool(job) { + Ok(m) => Ok((m, "ipp".into())), + Err(SpoolError::NoRoute(why)) => match spooler { + Some(sp) => app(sp), + None => Err(other(why)), + }, + Err(SpoolError::Failed(why)) => Err(other(why)), + } +} + +/// The spooler `craft-print` can talk to, as the job's `backend` name: `lp` when CUPS' command +/// is on the path, the Windows spooler on Windows. `None` in a sandbox without `lp` and in the +/// browser. +fn system_spooler() -> Option<&'static str> { + match craft_print::backend()? { + craft_print::Backend::WindowsPdf => Some("windows"), + craft_print::Backend::Cups => { + let path = std::env::var_os("PATH")?; + std::env::split_paths(&path).any(|dir| dir.join("lp").is_file()).then_some("lp") + } + } +} + +/// The printer a job goes to: the one asked for when the server knows it, else the default, +/// else the only one; otherwise an error that names what there is. +#[cfg(all(not(target_arch = "wasm32"), not(target_os = "windows")))] +fn resolve_printer(requested: Option<&str>, default: Option<&str>, printers: &[photocraft_ipp::Printer]) -> std::result::Result { + let names: Vec<&str> = printers.iter().map(|p| p.name.as_str()).collect(); + let known = |n: &str| names.iter().find(|k| k.eq_ignore_ascii_case(n)).map(|k| (*k).to_string()); + if let Some(r) = requested { + let choices = if names.is_empty() { "none".to_string() } else { names.join(", ") }; + return known(r).ok_or_else(|| SpoolError::Failed(format!("no printer called `{r}`; the print server knows: {choices}"))); + } + if printers.is_empty() { + return Err(SpoolError::NoRoute("no printer is set up on this computer; add one in the system settings, or print to PDF".into())); + } + if let Some(d) = default.and_then(known) { + return Ok(d); + } + match names.as_slice() { + [only] => Ok((*only).to_string()), + _ => Err(SpoolError::Failed(format!("no default printer; choose one in the Printer field: {}", names.join(", ")))), + } +} + +#[cfg(all(not(target_arch = "wasm32"), not(target_os = "windows")))] +fn cups_server(named: Option<&str>) -> std::result::Result { + match named { + Some(server) => { + let ep = photocraft_ipp::Endpoint::parse(server); + if !ep.reachable() { + return Err(SpoolError::Failed(format!("no print server at {ep}"))); + } + Ok(ep) + } + None => photocraft_ipp::Endpoint::detect() + .ok_or_else(|| SpoolError::NoRoute("no print server: CUPS is not running, or not reachable from this sandbox; print to PDF instead".into())), + } +} + +/// The IPP route: the job to the system's (or the named) CUPS server. +#[cfg(all(not(target_arch = "wasm32"), not(target_os = "windows")))] +fn cups_spool(job: &PrintJob) -> std::result::Result { + let failed = |e: photocraft_ipp::Error| SpoolError::Failed(e.to_string()); + let ep = cups_server(job.server.as_deref())?; + let printers = photocraft_ipp::printers(&ep).map_err(failed)?; + let default = printers.iter().find(|p| p.is_default).map(|p| p.name.clone()); + let printer = resolve_printer(job.printer.as_deref(), default.as_deref(), &printers)?; + let is_class = printers.iter().any(|p| p.name == printer && p.is_class); + let user = std::env::var("USER").or_else(|_| std::env::var("USERNAME")).unwrap_or_else(|_| "photocraft".into()); + let id = photocraft_ipp::print_job( + &ep, + &photocraft_ipp::Job { printer: &printer, is_class, title: &job.title, user: &user, copies: job.copies, document: &job.pdf, format: "application/pdf" }, + ) + .map_err(failed)?; + Ok(format!("job {id} on {printer}")) +} + +/// No IPP route here: a named server is a failure, the system's a missing route (the app's +/// spooler may have one). +#[cfg(any(target_arch = "wasm32", target_os = "windows"))] +fn cups_spool(job: &PrintJob) -> std::result::Result { + let why = if cfg!(target_arch = "wasm32") { + "printing needs the desktop app" + } else { + "a print server over IPP is not available on Windows yet; print to PDF instead" + }; + match job.server { + Some(_) => Err(SpoolError::Failed(why.into())), + None => Err(SpoolError::NoRoute(why.into())), + } +} + +/// `file.printers`: the printers the system's (or `printServer`'s) print server knows, with the default. From +/// `craft-print` (`lpstat`, the Windows spooler) where that is the route; over IPP for a named +/// server and where there is no `lp` (a sandbox). +fn system_printers(_s: &mut Session, p: &Value) -> Result { + let named = p.get("printServer").and_then(Value::as_str).filter(|v| !v.is_empty()); + if named.is_none() && system_spooler().is_some() { + let list = craft_print::printers(); + let default = list.iter().find(|p| p.default).map(|p| p.name.clone()); + let printers: Vec = list.into_iter().map(|p| json!({"name": p.name, "default": p.default})).collect(); + return Ok(json!({"server": Value::Null, "default": default, "printers": printers})); + } + #[cfg(all(not(target_arch = "wasm32"), not(target_os = "windows")))] + { + let ep = match cups_server(named) { + Ok(ep) => ep, + Err(SpoolError::NoRoute(why) | SpoolError::Failed(why)) => { + return Ok(json!({"server": Value::Null, "default": Value::Null, "printers": [], "note": why})); + } + }; + let list = photocraft_ipp::printers(&ep).map_err(other)?; + let default = list.iter().find(|p| p.is_default).map(|p| p.name.clone()); + let printers: Vec = list + .iter() + .map(|p| { + json!({"name": p.name, "info": p.info, "state": match p.state { 4 => "printing", 5 => "stopped", _ => "idle" }, "acceptingJobs": p.accepting_jobs, "default": p.is_default, "class": p.is_class}) + }) + .collect(); + Ok(json!({"server": ep.to_string(), "default": default, "printers": printers})) + } + #[cfg(not(all(not(target_arch = "wasm32"), not(target_os = "windows"))))] + { + let _ = named; + Ok(json!({"server": Value::Null, "default": Value::Null, "printers": [], "note": "listing printers is not available on this platform yet"})) + } +} + // ---------- PDF ---------- /// Paper sizes in points (portrait). @@ -343,6 +534,7 @@ fn do_print(s: &mut Session, p: &Value, cmd: &str) -> Result { let params = p.clone(); let worker_params = params.clone(); let command = cmd.to_string(); + let spooler = s.print_spooler.clone(); if send && !dry { // Session::execute remains synchronous; the desktop's Session::start snapshots here // and runs PDF composition and the potentially slow driver on a worker. @@ -352,7 +544,7 @@ fn do_print(s: &mut Session, p: &Value, cmd: &str) -> Result { false, move |ctx| { ctx.progress(0.0, "Print"); - render_and_spool(&doc, &worker_params, &command) + render_and_spool(&doc, &worker_params, &command, spooler.as_ref()) }, move |s, result| { remember_print(s, ¶ms); @@ -370,13 +562,13 @@ fn do_print(s: &mut Session, p: &Value, cmd: &str) -> Result { }, ); } - let result = render_and_spool(&doc, p, cmd)?; + let result = render_and_spool(&doc, p, cmd, spooler.as_ref())?; remember_print(s, p); crate::automate_cmds::fire_event(s, "print"); Ok(result) } -fn render_and_spool(doc: &Document, p: &Value, cmd: &str) -> Result { +fn render_and_spool(doc: &Document, p: &Value, cmd: &str, spooler: Option<&Spooler>) -> Result { let PrintLayout { paper: (pw, ph), rect: (x, y, w, h), scale, marks, emulsion_down } = layout(doc, p, cmd)?; let (img, color) = print_image(doc, p, cmd)?; let description = p @@ -394,7 +586,12 @@ fn render_and_spool(doc: &Document, p: &Value, cmd: &str) -> Result { // Sending follows the shared command contract; fuzz tests explicitly request dryRun. // The worker returns its result before repeat settings and hooks are applied once. let handling = p.get("colorHandling").and_then(Value::as_str).unwrap_or("printerManages"); - if cfg!(windows) && send && !dry && output.is_none() && native_rgb_compatible(&page, handling) { + // A named print server always takes the IPP route and a preferred app spooler takes every + // job (the route order in `spool_job`), on Windows too: neither may enter the native path + // below, which would print on the local default printer instead. + let named_server = p.get("printServer").and_then(Value::as_str).is_some_and(|v| !v.is_empty()); + let skip_native = named_server || spooler.is_some_and(|sp| sp.preferred); + if cfg!(windows) && !skip_native && send && !dry && output.is_none() && native_rgb_compatible(&page, handling) { require_printing_enabled()?; let printed = print_windows(&page, p, &doc.name, copies)?; return Ok(json!({ @@ -402,10 +599,11 @@ fn render_and_spool(doc: &Document, p: &Value, cmd: &str) -> Result { "copies": copies, "command": Value::Null, "sent": true, "spooler": printed, "color": color, })); } - if cfg!(windows) && send && !dry && output.is_none() && printer_settings(p)?.is_some() { + if cfg!(windows) && !skip_native && send && !dry && output.is_none() && printer_settings(p)?.is_some() { return Err(other("the selected color profile or page text requires PDF printing. Save as PDF, or clear Print Settings to use PDF printing.")); } let pdf = print_pdf(&page); + let pdf_len = pdf.len(); let job = craft_print::Job { printer: p.get("printer").and_then(Value::as_str).filter(|v| !v.is_empty()).map(str::to_string), copies, @@ -425,13 +623,25 @@ fn render_and_spool(doc: &Document, p: &Value, cmd: &str) -> Result { }; let mut sent = false; let mut spool = Value::Null; + let mut backend = Value::Null; if send && !dry { require_printing_enabled()?; - spool = json!(craft_print::submit(&pdf, &job).map_err(other)?); + let print_job = PrintJob { + printer: job.printer.clone(), + copies, + title: doc.name.clone(), + server: p.get("printServer").and_then(Value::as_str).filter(|v| !v.is_empty()).map(str::to_string), + paper: (pw, ph), + landscape: p.get("orientation").and_then(Value::as_str) == Some("landscape"), + pdf, + }; + let (where_, via) = spool_job(spooler, &print_job, &job)?; + spool = json!(where_); + backend = json!(via); sent = true; } Ok( - json!({"pdf": pdf_path, "bytes": pdf.len(), "paper": [pw, ph], "imageRect": [x, y, w, h], "emulsionDown": emulsion_down, "scale": scale * 100.0, "copies": copies, "command": command, "sent": sent, "spooler": spool, "color": color}), + json!({"pdf": pdf_path, "bytes": pdf_len, "paper": [pw, ph], "imageRect": [x, y, w, h], "emulsionDown": emulsion_down, "scale": scale * 100.0, "copies": copies, "command": command, "sent": sent, "spooler": spool, "backend": backend, "color": color}), ) } @@ -573,15 +783,6 @@ fn require_printing_enabled() -> Result<()> { } } -/// The printers the system spooler knows (Print's Printer menu): CUPS queues and driverless -/// destinations on macOS and Linux, `Win32_Printer` on Windows, none in the browser. -fn system_printers(_: &mut Session, _: &Value) -> Result { - let list = craft_print::printers(); - let default = list.iter().find(|p| p.default).map(|p| p.name.clone()); - let printers: Vec = list.into_iter().map(|p| json!({"name": p.name, "default": p.default})).collect(); - Ok(json!({"printers": printers, "default": default})) -} - fn print(s: &mut Session, p: &Value) -> Result { do_print(s, p, "file.print") } @@ -759,7 +960,7 @@ pub fn specs() -> Vec { CommandSpec { id: $id, label: $label, menu: $menu, shortcut: $sc, params: $params, enabled: $enabled, journal: true, run: $run } }; } - const PRINT_PARAMS: &str = r##"{"printerSettings":hex? (Windows driver settings),"printer":name? (default printer),"copies":1..999=1,"paper":"letter|legal|tabloid|a3|a4|a5|4x6|5x7"|[w,h] pt="letter","orientation":"portrait|landscape"="portrait","center":bool=true,"top":in?,"left":in?,"scale":%=100,"scaleToFit":bool=false,"colorHandling":"printerManages|photocraftManages|noColorManagement"="printerManages","printerProfile":profile? (photocraftManages),"intent":"perceptual|relative|saturation|absolute"="relative","bpc":bool=true,"cornerCropMarks":bool,"centerCropMarks":bool,"registrationMarks":bool,"description":bool,"labels":bool,"emulsionDown":bool=false (mirror the page left to right, for sublimation and other transfer printing),"output":pdf path? (print to PDF; then "send" defaults to false),"send":bool?,"dryRun":bool=false (render the PDF, report the backend command, don't spool; write a PDF only at an explicit output path)} → {pdf:path|null (null without explicit output), imageRect, command:diagnostic array|null, sent}"##; + const PRINT_PARAMS: &str = r##"{"printerSettings":hex? (Windows driver settings),"printer":name? (default printer),"copies":1..999=1,"paper":"letter|legal|tabloid|a3|a4|a5|4x6|5x7"|[w,h] pt="letter","orientation":"portrait|landscape"="portrait","center":bool=true,"top":in?,"left":in?,"scale":%=100,"scaleToFit":bool=false,"colorHandling":"printerManages|photocraftManages|noColorManagement"="printerManages","printerProfile":profile? (photocraftManages),"intent":"perceptual|relative|saturation|absolute"="relative","bpc":bool=true,"cornerCropMarks":bool,"centerCropMarks":bool,"registrationMarks":bool,"description":bool,"labels":bool,"emulsionDown":bool=false (mirror the page left to right, for sublimation and other transfer printing),"output":pdf path? (print to PDF; then "send" defaults to false),"send":bool?,"printServer":"host[:port]|socket path"? (default: the system's CUPS; a named server is always printed to over IPP),"dryRun":bool=false (render the PDF, report the backend command, don't spool; write a PDF only at an explicit output path)} → {pdf:path|null (null without explicit output), imageRect, command:diagnostic array|null, sent, spooler:where the job went|null, backend:"lp|windows|ipp|portal"|null}"##; vec![ spec!("file.print", "Print…", &["File"], Some("Cmd+P"), PRINT_PARAMS, native_doc, print), diff --git a/crates/engine/src/print_cmds/tests.rs b/crates/engine/src/print_cmds/tests.rs index d012f6de41..34c1cda6fa 100644 --- a/crates/engine/src/print_cmds/tests.rs +++ b/crates/engine/src/print_cmds/tests.rs @@ -360,7 +360,7 @@ fn spool_transport_streams_pdf_and_propagates_refusal() { let printed = &s.documents().iter().find(|st| st.doc.id == source).unwrap().doc; assert_eq!((printed.size.width, printed.size.height), (150, 300), "print hook ran on the source document"); assert_eq!(out["sent"], true); - assert_eq!(out["spooler"], "request id is Office-42"); + assert_eq!((out["spooler"].as_str(), out["backend"].as_str()), (Some("request id is Office-42"), Some("lp"))); assert!(out["pdf"].is_null(), "spool-only printing leaves no diagnostic PDF"); let pdf = std::fs::read(format!("{dir}/received.pdf")).unwrap(); assert!(pdf.starts_with(b"%PDF-1.4")); @@ -434,3 +434,292 @@ fn printers_lists_the_spooler_destinations_without_a_document() { None => assert!(list.iter().all(|p| p["default"] == false)), } } + +/// Spooling tests run in a child process: `disable_printing` is process-wide and a test above +/// sets it, and CI sets `PHOTOCRAFT_NO_PRINT` for the whole shell. True in the child, which runs +/// the test body; the parent starts the child and checks that it passed. +fn in_child(test: &str, envs: &[(&str, &str)]) -> bool { + const CHILD: &str = "PHOTOCRAFT_TEST_PRINT_CHILD"; + if std::env::var_os(CHILD).is_some() { + return true; + } + let mut cmd = std::process::Command::new(std::env::current_exe().unwrap()); + cmd.args(["--exact", test, "--nocapture"]).env(CHILD, "1").env_remove("PHOTOCRAFT_NO_PRINT"); + for (k, v) in envs { + cmd.env(k, v); + } + // Bounded: a child stuck in a printer dialog or a driver must not hang the suite. + let mut child = cmd.stdout(std::process::Stdio::piped()).stderr(std::process::Stdio::piped()).spawn().unwrap(); + let deadline = std::time::Instant::now() + std::time::Duration::from_secs(120); + let status = loop { + if let Some(status) = child.try_wait().unwrap() { + break Some(status); + } + if std::time::Instant::now() > deadline { + let _ = child.kill(); + break None; + } + std::thread::sleep(std::time::Duration::from_millis(50)); + }; + let out = child.wait_with_output().unwrap(); + assert!( + status.is_some_and(|s| s.success()), + "{}\nstdout: {}\nstderr: {}", + if status.is_none() { "the child did not finish within 120 s and was killed" } else { "the child failed" }, + String::from_utf8_lossy(&out.stdout), + String::from_utf8_lossy(&out.stderr) + ); + false +} + +/// A spooler that keeps every job it gets. +fn recording_spooler(preferred: bool, seen: &std::sync::Arc>>) -> Spooler { + let seen = std::sync::Arc::clone(seen); + Spooler { + name: "test", + preferred, + run: std::sync::Arc::new(move |job: &PrintJob| { + seen.lock().unwrap().push(job.clone()); + Ok("queued".into()) + }), + } +} + +#[test] +fn a_preferred_app_spooler_takes_the_job() { + if !in_child("print_cmds::tests::a_preferred_app_spooler_takes_the_job", &[]) { + return; + } + let seen = std::sync::Arc::new(std::sync::Mutex::new(Vec::new())); + let mut s = session("rgb", 8); + s.print_spooler = Some(recording_spooler(true, &seen)); + let r = s.execute("file.print", json!({"copies": 2, "paper": "a4", "orientation": "landscape"})).unwrap(); + assert_eq!((r["sent"].as_bool(), r["backend"].as_str(), r["spooler"].as_str()), (Some(true), Some("test"), Some("queued"))); + assert!(r["pdf"].is_null(), "{r}"); + let jobs = seen.lock().unwrap(); + let job = &jobs[0]; + assert!(job.pdf.starts_with(b"%PDF-1.4")); + assert_eq!((job.copies, job.title.as_str(), job.printer.as_deref(), job.landscape), (2, "Print Me.psd", None, true)); + assert!((job.paper.0 - 841.89).abs() < 0.01 && (job.paper.1 - 595.28).abs() < 0.01); + drop(jobs); + // A named print server asks for the IPP route, preferred spooler or not. + let e = s.execute("file.print", json!({"printServer": "127.0.0.1:1"})).unwrap_err().to_string(); + assert!(e.contains("no print server at 127.0.0.1:1") || e.contains("not available on Windows"), "{e}"); + assert_eq!(seen.lock().unwrap().len(), 1); + // "output" keeps the file and does not spool unless asked. + let out = format!("{}/out.pdf", tmp("output")); + let r = s.execute("file.print", json!({"output": out})).unwrap(); + assert_eq!((r["sent"].as_bool(), r["pdf"].as_str()), (Some(false), Some(out.as_str()))); + assert!(std::path::Path::new(&out).exists()); +} + +#[cfg(not(target_os = "windows"))] +mod cups { + use super::*; + use photocraft_ipp::{ + Attribute, OP_CUPS_GET_CLASSES, OP_CUPS_GET_DEFAULT, OP_CUPS_GET_PRINTERS, OP_PRINT_JOB, Printer, Request, TAG_JOB_GROUP, TAG_NAME, TAG_PRINTER_GROUP, + }; + use std::io::{BufRead, BufReader, Read, Write}; + use std::sync::mpsc; + + fn printer(name: &str, is_default: bool) -> Printer { + Printer { name: name.into(), info: String::new(), state: 3, accepting_jobs: true, is_default, is_class: false } + } + + #[test] + fn the_printer_is_the_requested_one_else_the_default_else_the_only_one() { + let two = [printer("Office", false), printer("Lab", true)]; + assert_eq!(resolve_printer(None, Some("Lab"), &two).ok(), Some("Lab".into())); + assert_eq!(resolve_printer(Some("office"), Some("Lab"), &two).ok(), Some("Office".into())); + assert_eq!(resolve_printer(None, None, &two[..1]).ok(), Some("Office".into())); + match resolve_printer(None, None, &two) { + Err(SpoolError::Failed(e)) => assert!(e.contains("no default printer") && e.contains("Office, Lab"), "{e}"), + _ => panic!("several printers and no default must fail"), + } + match resolve_printer(Some("Nope"), Some("Lab"), &two) { + Err(SpoolError::Failed(e)) => assert!(e.contains("`Nope`") && e.contains("Office, Lab"), "{e}"), + _ => panic!("an unknown printer must fail"), + } + match resolve_printer(None, None, &[]) { + Err(SpoolError::NoRoute(e)) => assert!(e.contains("no printer is set up"), "{e}"), + _ => panic!("no printers is no route"), + } + // A printer asked for by name on a server without any is a failure, not a missing route. + match resolve_printer(Some("Office"), None, &[]) { + Err(SpoolError::Failed(e)) => assert!(e.contains("`Office`") && e.contains("none"), "{e}"), + _ => panic!("a named printer never falls through"), + } + // A stale default that the server no longer lists falls through to the only printer. + assert_eq!(resolve_printer(None, Some("Gone"), &two[..1]).ok(), Some("Office".into())); + } + + /// What the fake received per request: op, attributes, document. + type Seen = (u16, Vec, Vec); + + /// A fake CUPS on a TCP port: lists `printers` and `classes`, names `default`, accepts jobs + /// as id 42. Answers for as long as the test process lives. + fn fake_cups(printers: &'static [&'static str], classes: &'static [&'static str], default: Option<&'static str>) -> (String, mpsc::Receiver) { + let listener = std::net::TcpListener::bind("127.0.0.1:0").unwrap(); + let server = format!("127.0.0.1:{}", listener.local_addr().unwrap().port()); + let (tx, rx) = mpsc::channel(); + std::thread::spawn(move || { + for stream in listener.incoming() { + let Ok(stream) = stream else { break }; + let mut reader = BufReader::new(stream); + let (mut first, mut line, mut len) = (String::new(), String::new(), 0usize); + loop { + line.clear(); + if reader.read_line(&mut line).unwrap_or(0) == 0 || line == "\r\n" { + break; + } + if first.is_empty() { + first = line.clone(); + } + if let Some(v) = line.to_ascii_lowercase().strip_prefix("content-length:") { + len = v.trim().parse().unwrap(); + } + } + if first.is_empty() { + continue; // a reachability probe + } + let mut body = vec![0u8; len]; + reader.read_exact(&mut body).unwrap(); + let (op, attrs, doc) = photocraft_ipp::parse_request(&body).unwrap(); + let list = |names: &[&str]| { + if names.is_empty() { + return Request::new(0x0406, 1).end(); + } + let mut r = Request::new(0, 1); + for p in names { + r.group(TAG_PRINTER_GROUP); + r.attr(TAG_NAME, "printer-name", p.as_bytes()); + } + r.end() + }; + let reply = match op { + OP_CUPS_GET_PRINTERS => list(printers), + OP_CUPS_GET_CLASSES => list(classes), + OP_CUPS_GET_DEFAULT => match default { + Some(d) => { + let mut r = Request::new(0, 1); + r.group(TAG_PRINTER_GROUP); + r.attr(TAG_NAME, "printer-name", d.as_bytes()); + r.end() + } + None => Request::new(0x0406, 1).end(), + }, + OP_PRINT_JOB => { + let mut r = Request::new(0, 1); + r.group(TAG_JOB_GROUP); + r.integer("job-id", 42); + r.end() + } + _ => Request::new(0x0501, 1).end(), + }; + let _ = tx.send((op, attrs, doc)); + let mut out = reader.into_inner(); + let _ = out.write_all(format!("HTTP/1.1 200 OK\r\nContent-Type: application/ipp\r\nContent-Length: {}\r\n\r\n", reply.len()).as_bytes()); + let _ = out.write_all(&reply); + let _ = out.flush(); + } + }); + (server, rx) + } + + #[test] + fn a_named_server_is_printed_to_over_ipp() { + if !in_child("print_cmds::tests::cups::a_named_server_is_printed_to_over_ipp", &[]) { + return; + } + let (server, rx) = fake_cups(&["Office"], &["Lab"], Some("Lab")); + let mut s = session("rgb", 8); + let r = s.execute("file.print", json!({"printServer": server, "copies": 3})).unwrap(); + assert_eq!((r["sent"].as_bool(), r["backend"].as_str(), r["spooler"].as_str()), (Some(true), Some("ipp"), Some("job 42 on Lab"))); + assert!(r["pdf"].is_null()); + let job = std::iter::from_fn(|| rx.try_recv().ok()).find(|(op, _, _)| *op == OP_PRINT_JOB).expect("a Print-Job request"); + let attr = |name: &str| job.1.iter().find(|a| a.name == name).unwrap_or_else(|| panic!("{name}")); + assert!(attr("printer-uri").text().ends_with("/classes/Lab"), "{}", attr("printer-uri").text()); + assert_eq!( + (attr("copies").int(), attr("job-name").text().as_str(), attr("document-format").text().as_str()), + (Some(3), "Print Me.psd", "application/pdf") + ); + assert!(job.2.starts_with(b"%PDF-1.4")); + // The requested printer, in any case; an unknown one names the choices. + let r = s.execute("file.print", json!({"printServer": server, "printer": "office"})).unwrap(); + assert_eq!(r["spooler"], "job 42 on Office"); + let job = std::iter::from_fn(|| rx.try_recv().ok()).filter(|(op, _, _)| *op == OP_PRINT_JOB).last().expect("the second Print-Job"); + assert!(job.1.iter().any(|a| a.name == "printer-uri" && a.text().ends_with("/printers/Office"))); + let e = s.execute("file.print", json!({"printServer": server, "printer": "Nope"})).unwrap_err().to_string(); + assert!(e.contains("`Nope`") && e.contains("Office, Lab"), "{e}"); + // Printers can be listed. + let r = s.execute("file.printers", json!({"printServer": server})).unwrap(); + let names: Vec<&str> = r["printers"].as_array().unwrap().iter().map(|p| p["name"].as_str().unwrap()).collect(); + assert_eq!((names, r["default"].as_str()), (vec!["Office", "Lab"], Some("Lab"))); + assert_eq!( + (r["printers"][1]["default"].as_bool(), r["printers"][1]["class"].as_bool(), r["printers"][0]["class"].as_bool()), + (Some(true), Some(true), Some(false)) + ); + } + + #[test] + fn without_a_printer_the_app_spooler_takes_over() { + if !in_child("print_cmds::tests::cups::without_a_printer_the_app_spooler_takes_over", &[]) { + return; + } + let (server, _rx) = fake_cups(&[], &[], None); + let mut s = session("gray", 8); + let e = s.execute("file.print", json!({"printServer": server})).unwrap_err().to_string(); + assert!(e.contains("no printer is set up"), "{e}"); + // Asking for a printer by name never falls through to another route. + let e = s.execute("file.print", json!({"printServer": server, "printer": "Office"})).unwrap_err().to_string(); + assert!(e.contains("`Office`"), "{e}"); + let seen = std::sync::Arc::new(std::sync::Mutex::new(Vec::new())); + s.print_spooler = Some(recording_spooler(false, &seen)); + let r = s.execute("file.print", json!({"printServer": server})).unwrap(); + assert_eq!((r["backend"].as_str(), r["spooler"].as_str()), (Some("test"), Some("queued"))); + assert_eq!(seen.lock().unwrap().len(), 1); + // An unreachable named server is a failure, not a missing route. + let e = s.execute("file.print", json!({"printServer": "127.0.0.1:1"})).unwrap_err().to_string(); + assert!(e.contains("no print server at"), "{e}"); + let r = s.execute("file.printers", json!({"printServer": "127.0.0.1:1"})).unwrap(); + assert!(r["printers"].as_array().unwrap().is_empty() && r["note"].as_str().unwrap().contains("no print server")); + } + + /// The Flatpak case: no `lp` on the path, so the job goes to the system's CUPS over IPP + /// (`CUPS_SERVER` names it here), and with no print server at all to the app's spooler. + /// Runs in a child process, since the path is process-wide. + #[test] + fn without_lp_the_job_goes_over_ipp_then_to_the_app_spooler() { + const CHILD: &str = "PHOTOCRAFT_TEST_PRINT_NO_LP"; + if std::env::var_os(CHILD).is_some() { + let mut s = session("rgb", 8); + let r = s.execute("file.print", json!({"copies": 2})).unwrap(); + assert_eq!((r["sent"].as_bool(), r["backend"].as_str(), r["spooler"].as_str()), (Some(true), Some("ipp"), Some("job 42 on Office"))); + let seen = std::sync::Arc::new(std::sync::Mutex::new(Vec::new())); + s.print_spooler = Some(recording_spooler(false, &seen)); + // The IPP route first, then the app's spooler only when there is no server. + let r = s.execute("file.print", json!({})).unwrap(); + assert_eq!(r["backend"], "ipp"); + assert!(seen.lock().unwrap().is_empty()); + // (CUPS_SERVER is read per job; an unreachable one is no route.) + let r = s.execute("file.print", json!({"printServer": "127.0.0.1:1"})).unwrap_err().to_string(); + assert!(r.contains("no print server at"), "{r}"); + println!("child ok"); + return; + } + let (server, rx) = fake_cups(&["Office"], &[], Some("Office")); + let no_lp = tempfile::tempdir().unwrap(); + let out = std::process::Command::new(std::env::current_exe().unwrap()) + .args(["--exact", "print_cmds::tests::cups::without_lp_the_job_goes_over_ipp_then_to_the_app_spooler", "--nocapture"]) + .env(CHILD, "1") + .env("PATH", no_lp.path()) + .env("CUPS_SERVER", &server) + .env_remove("PHOTOCRAFT_NO_PRINT") + .output() + .unwrap(); + let (stdout, stderr) = (String::from_utf8_lossy(&out.stdout), String::from_utf8_lossy(&out.stderr)); + assert!(out.status.success() && stdout.contains("child ok"), "stdout: {stdout}\nstderr: {stderr}"); + let jobs = std::iter::from_fn(|| rx.try_recv().ok()).filter(|(op, _, _)| *op == OP_PRINT_JOB).count(); + assert_eq!(jobs, 2, "both prints reached the fake CUPS"); + } +} diff --git a/crates/ipp/Cargo.toml b/crates/ipp/Cargo.toml new file mode 100644 index 0000000000..cc9f6aec31 --- /dev/null +++ b/crates/ipp/Cargo.toml @@ -0,0 +1,12 @@ +[package] +name = "photocraft-ipp" +description = "A small IPP client for CUPS (list printers, default printer, print a PDF), pure Rust, no dependencies" +version.workspace = true +edition.workspace = true +license.workspace = true +rust-version.workspace = true + +[dependencies] + +[lints] +workspace = true diff --git a/crates/ipp/README.md b/crates/ipp/README.md new file mode 100644 index 0000000000..a6c03d1113 --- /dev/null +++ b/crates/ipp/README.md @@ -0,0 +1,22 @@ +# photocraft-ipp + +A small IPP (Internet Printing Protocol, RFC 8010/8011) client for the local CUPS server, in pure +Rust with no dependencies. It does in-process what `lp` does from the command line, so PhotoCraft +can print where there is no `lp` binary (a Flatpak sandbox with `--socket=cups`, a minimal +container) and can read the printer list and the default printer first instead of handing +CUPS's errors to the user. + +```rust +let ep = photocraft_ipp::Endpoint::detect().ok_or("no CUPS server")?; // socket, then localhost:631 +let printers = photocraft_ipp::printers(&ep)?; // name, info, state, default +let id = photocraft_ipp::print_job(&ep, &photocraft_ipp::Job { printer: "Office", title: "photo", user: "me", copies: 2, document: &pdf, format: "application/pdf" })?; +``` + +Three operations: `CUPS-Get-Printers`, `CUPS-Get-Default`, `Print-Job`. HTTP/1.1 over the CUPS +domain socket (`/run/cups/cups.sock`, or `CUPS_SERVER` as a path) or TCP (`localhost:631`, or +`CUPS_SERVER` as `host[:port]`), chunked replies included. Replies are capped at 16 MiB and +parsed with bounds checks; an IPP error status becomes `Error::Status` with the server's own +message. Tests run against an in-process fake server over TCP and a Unix socket. + +Standalone: it depends on no other PhotoCraft crate. Used by `photocraft-engine`'s print +commands on Unix; Windows printing lives in `photocraft-winprint`. diff --git a/crates/ipp/src/lib.rs b/crates/ipp/src/lib.rs new file mode 100644 index 0000000000..9f1209df45 --- /dev/null +++ b/crates/ipp/src/lib.rs @@ -0,0 +1,600 @@ +//! `photocraft-ipp`: a small IPP client for CUPS, in pure Rust with no dependencies. +//! +//! Talks to the local print server the way `lp` does, but in-process: over the CUPS domain +//! socket (`/run/cups/cups.sock`, or `CUPS_SERVER` when it names a path) or TCP (`localhost:631`, +//! or `CUPS_SERVER` as `host[:port]`). Three operations, from RFC 8010 / 8011 and CUPS's own +//! extensions: list the printers, read the default, and submit a PDF as a job. +//! +//! Never panics: every read is bounded and every malformed reply is an error. Works on any Unix +//! and on Windows for TCP (CUPS rarely runs there; the Windows print path is separate). + +#![forbid(unsafe_code)] +#![deny(clippy::unwrap_used, clippy::expect_used, clippy::panic, clippy::unimplemented, clippy::todo, clippy::unreachable)] + +use std::io::{Read, Write}; +use std::net::{TcpStream, ToSocketAddrs}; +use std::path::PathBuf; +use std::time::Duration; + +/// Where the print server listens. +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum Endpoint { + /// A Unix domain socket (CUPS's default, also what a Flatpak sees with `--socket=cups`). + Unix(PathBuf), + /// A TCP host and port. + Tcp(String, u16), +} + +/// Why a request failed. +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum Error { + /// No server could be reached. + Unreachable(String), + /// The HTTP or IPP reply could not be read. + Protocol(String), + /// The server answered with an IPP error status (the code and its message). + Status(u16, String), +} + +impl std::fmt::Display for Error { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + match self { + Error::Unreachable(m) => write!(f, "no print server: {m}"), + Error::Protocol(m) => write!(f, "print server reply: {m}"), + Error::Status(code, m) => write!(f, "print server refused ({code:#06x}): {m}"), + } + } +} + +impl std::error::Error for Error {} + +type Result = std::result::Result; + +/// A printer the server knows. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct Printer { + pub name: String, + /// `printer-info`, the human description. + pub info: String, + /// `printer-state`: 3 idle, 4 printing, 5 stopped. + pub state: u32, + pub accepting_jobs: bool, + pub is_default: bool, + /// A CUPS class (a pool of printers) rather than one printer. + pub is_class: bool, +} + +/// Timeouts: connecting, and each request (a Print-Job carries the whole PDF). +const CONNECT: Duration = Duration::from_secs(3); +const REQUEST: Duration = Duration::from_secs(60); +/// Largest reply read. +const MAX_REPLY: usize = 16 << 20; +/// Largest number of attributes taken from a reply. +const MAX_ATTRIBUTES: usize = 100_000; + +pub const OP_PRINT_JOB: u16 = 0x0002; +pub const OP_CUPS_GET_DEFAULT: u16 = 0x4001; +pub const OP_CUPS_GET_PRINTERS: u16 = 0x4002; +pub const OP_CUPS_GET_CLASSES: u16 = 0x4005; + +pub const TAG_OPERATION_GROUP: u8 = 0x01; +pub const TAG_JOB_GROUP: u8 = 0x02; +pub const TAG_END: u8 = 0x03; +pub const TAG_PRINTER_GROUP: u8 = 0x04; +pub const TAG_INTEGER: u8 = 0x21; +pub const TAG_BOOLEAN: u8 = 0x22; +pub const TAG_ENUM: u8 = 0x23; +pub const TAG_TEXT: u8 = 0x41; +pub const TAG_NAME: u8 = 0x42; +pub const TAG_KEYWORD: u8 = 0x44; +pub const TAG_URI: u8 = 0x45; +pub const TAG_CHARSET: u8 = 0x47; +pub const TAG_LANGUAGE: u8 = 0x48; +pub const TAG_MIME: u8 = 0x49; + +impl Endpoint { + /// The server this machine uses: `CUPS_SERVER` (a socket path, or `host[:port]`), else the + /// CUPS socket, else `localhost:631`. `None` when nothing answers. + pub fn detect() -> Option { + if let Some(server) = std::env::var_os("CUPS_SERVER").filter(|v| !v.is_empty()) { + let ep = Endpoint::parse(&server.to_string_lossy()); + return ep.reachable().then_some(ep); + } + [ + Endpoint::Unix(PathBuf::from("/run/cups/cups.sock")), + Endpoint::Unix(PathBuf::from("/var/run/cups/cups.sock")), + // macOS + Endpoint::Unix(PathBuf::from("/private/var/run/cupsd")), + Endpoint::Tcp("localhost".into(), 631), + ] + .into_iter() + .find(Endpoint::reachable) + } + + /// `server` as a socket path (starts with `/`) or `host[:port]` (port 631 by default). + pub fn parse(server: &str) -> Endpoint { + if server.starts_with('/') { + return Endpoint::Unix(PathBuf::from(server)); + } + let (host, port) = server.rsplit_once(':').and_then(|(h, p)| p.parse().ok().map(|p| (h.to_string(), p))).unwrap_or((server.to_string(), 631)); + Endpoint::Tcp(host, port) + } + + /// Whether a connection opens. + pub fn reachable(&self) -> bool { + self.connect().is_ok() + } + + fn connect(&self) -> Result { + match self { + #[cfg(unix)] + Endpoint::Unix(path) => { + if !path.exists() { + return Err(Error::Unreachable(format!("{} does not exist", path.display()))); + } + let s = std::os::unix::net::UnixStream::connect(path).map_err(|e| Error::Unreachable(format!("{}: {e}", path.display())))?; + let _ = s.set_read_timeout(Some(REQUEST)); + let _ = s.set_write_timeout(Some(REQUEST)); + Ok(Stream::Unix(s)) + } + #[cfg(not(unix))] + Endpoint::Unix(path) => Err(Error::Unreachable(format!("{}: Unix sockets are not available here", path.display()))), + Endpoint::Tcp(host, port) => { + let addr = (host.as_str(), *port).to_socket_addrs().map_err(|e| Error::Unreachable(format!("{host}:{port}: {e}")))?.next(); + let addr = addr.ok_or_else(|| Error::Unreachable(format!("{host}:{port}: no address")))?; + let s = TcpStream::connect_timeout(&addr, CONNECT).map_err(|e| Error::Unreachable(format!("{host}:{port}: {e}")))?; + let _ = s.set_read_timeout(Some(REQUEST)); + let _ = s.set_write_timeout(Some(REQUEST)); + Ok(Stream::Tcp(s)) + } + } + } + + fn host(&self) -> String { + match self { + Endpoint::Unix(_) => "localhost".into(), + Endpoint::Tcp(host, port) => format!("{host}:{port}"), + } + } +} + +impl std::fmt::Display for Endpoint { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + match self { + Endpoint::Unix(p) => write!(f, "{}", p.display()), + Endpoint::Tcp(h, p) => write!(f, "{h}:{p}"), + } + } +} + +enum Stream { + #[cfg(unix)] + Unix(std::os::unix::net::UnixStream), + Tcp(TcpStream), +} + +impl Read for Stream { + fn read(&mut self, buf: &mut [u8]) -> std::io::Result { + match self { + #[cfg(unix)] + Stream::Unix(s) => s.read(buf), + Stream::Tcp(s) => s.read(buf), + } + } +} + +impl Write for Stream { + fn write(&mut self, buf: &[u8]) -> std::io::Result { + match self { + #[cfg(unix)] + Stream::Unix(s) => s.write(buf), + Stream::Tcp(s) => s.write(buf), + } + } + fn flush(&mut self) -> std::io::Result<()> { + match self { + #[cfg(unix)] + Stream::Unix(s) => s.flush(), + Stream::Tcp(s) => s.flush(), + } + } +} + +/// Builds an IPP request body. +pub struct Request { + buf: Vec, +} + +impl Request { + pub fn new(op: u16, id: u32) -> Self { + let mut buf = vec![2, 0]; + buf.extend_from_slice(&op.to_be_bytes()); + buf.extend_from_slice(&id.to_be_bytes()); + buf.push(TAG_OPERATION_GROUP); + let mut r = Request { buf }; + r.attr(TAG_CHARSET, "attributes-charset", b"utf-8"); + r.attr(TAG_LANGUAGE, "attributes-natural-language", b"en"); + r + } + + /// Appends an attribute to the current group. + pub fn attr(&mut self, tag: u8, name: &str, value: &[u8]) { + // IPP lengths are 16-bit; longer names and values are cut, both the length and the bytes. + let name = name.as_bytes().get(..name.len().min(0xffff)).unwrap_or_default(); + let value = value.get(..value.len().min(0xffff)).unwrap_or_default(); + self.buf.push(tag); + self.buf.extend_from_slice(&(name.len() as u16).to_be_bytes()); + self.buf.extend_from_slice(name); + self.buf.extend_from_slice(&(value.len() as u16).to_be_bytes()); + self.buf.extend_from_slice(value); + } + + /// An attribute with several values (the extra ones carry an empty name). + pub fn attr_many(&mut self, tag: u8, name: &str, values: &[&str]) { + for (i, v) in values.iter().enumerate() { + self.attr(tag, if i == 0 { name } else { "" }, v.as_bytes()); + } + } + + pub fn integer(&mut self, name: &str, v: i32) { + self.attr(TAG_INTEGER, name, &v.to_be_bytes()); + } + + /// Starts a new attribute group. + pub fn group(&mut self, tag: u8) { + self.buf.push(tag); + } + + /// The message with its end tag. + pub fn end(mut self) -> Vec { + self.buf.push(TAG_END); + self.buf + } +} + +/// One attribute of a reply: the group it sits in, its tag, name and values. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct Attribute { + pub group: u8, + pub tag: u8, + pub name: String, + pub values: Vec>, +} + +impl Attribute { + /// The first value as text. + pub fn text(&self) -> String { + self.values.first().map(|v| String::from_utf8_lossy(v).into_owned()).unwrap_or_default() + } + /// The first value as a big-endian integer. + pub fn int(&self) -> Option { + self.values.first().and_then(|v| <[u8; 4]>::try_from(v.as_slice()).ok()).map(i32::from_be_bytes) + } +} + +/// A decoded reply. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct Reply { + pub status: u16, + pub request_id: u32, + pub attributes: Vec, +} + +impl Reply { + /// Parses an IPP message (RFC 8010 §3): header, then groups of attributes until the end tag. + pub fn parse(b: &[u8]) -> Result { + Self::parse_at(b).map(|(r, _)| r) + } + + /// Parses a message and returns where its attributes end (document data may follow). + fn parse_at(b: &[u8]) -> Result<(Reply, usize)> { + let bad = |m: &str| Error::Protocol(m.to_string()); + if b.len() < 9 { + return Err(bad("reply shorter than an IPP header")); + } + let status = u16::from_be_bytes([b[2], b[3]]); + let request_id = u32::from_be_bytes([b[4], b[5], b[6], b[7]]); + let mut pos = 8; + let mut group = 0u8; + let mut attributes: Vec = Vec::new(); + loop { + let tag = *b.get(pos).ok_or_else(|| bad("reply ends before the end-of-attributes tag"))?; + pos += 1; + if tag == TAG_END { + return Ok((Reply { status, request_id, attributes }, pos)); + } + if tag < 0x10 { + group = tag; + continue; + } + let name_len = + usize::from(u16::from_be_bytes([*b.get(pos).ok_or_else(|| bad("cut attribute"))?, *b.get(pos + 1).ok_or_else(|| bad("cut attribute"))?])); + pos += 2; + let name = b.get(pos..pos + name_len).ok_or_else(|| bad("cut attribute name"))?; + pos += name_len; + let value_len = + usize::from(u16::from_be_bytes([*b.get(pos).ok_or_else(|| bad("cut attribute"))?, *b.get(pos + 1).ok_or_else(|| bad("cut attribute"))?])); + pos += 2; + let value = b.get(pos..pos + value_len).ok_or_else(|| bad("cut attribute value"))?.to_vec(); + pos += value_len; + if name_len == 0 { + // An additional value of the previous attribute. + match attributes.last_mut() { + Some(last) if last.group == group => last.values.push(value), + _ => return Err(bad("an additional value with no attribute before it")), + } + } else { + attributes.push(Attribute { group, tag, name: String::from_utf8_lossy(name).into_owned(), values: vec![value] }); + } + if attributes.len() > MAX_ATTRIBUTES { + return Err(bad("too many attributes")); + } + } + } + + /// The server's own message for an error status, or a name for the code. + fn status_message(&self) -> String { + self.attributes.iter().find(|a| a.name == "status-message").map(Attribute::text).filter(|m| !m.is_empty()).unwrap_or_else(|| { + match self.status { + 0x0400 => "bad request", + 0x0401 => "forbidden", + 0x0402 => "not authenticated", + 0x0403 => "not authorized", + 0x0406 => "not found", + 0x0409 => "unsupported document format", + 0x0500 => "internal server error", + 0x0506 => "the printer is not accepting jobs", + _ => "error", + } + .to_string() + }) + } +} + +/// POSTs `body` (IPP, with any document data after it) to `path` and returns the reply. +fn post(endpoint: &Endpoint, path: &str, body: &[u8]) -> Result { + let mut stream = endpoint.connect()?; + let head = format!( + "POST {path} HTTP/1.1\r\nHost: {}\r\nContent-Type: application/ipp\r\nContent-Length: {}\r\nConnection: close\r\n\r\n", + endpoint.host(), + body.len() + ); + stream + .write_all(head.as_bytes()) + .and_then(|()| stream.write_all(body)) + .and_then(|()| stream.flush()) + .map_err(|e| Error::Protocol(format!("sending: {e}")))?; + let raw = read_response(&mut stream)?; + let (status, headers, body) = http_response(&raw)?; + if status != 200 { + return Err(Error::Protocol(match status { + 401 => "the print server asks for authentication (HTTP 401)".to_string(), + 403 => "the print server forbids this request (HTTP 403)".to_string(), + s => format!("HTTP {s}"), + })); + } + let body = if headers.iter().any(|(k, v)| k == "transfer-encoding" && v.contains("chunked")) { dechunk(body)? } else { body.to_vec() }; + let reply = Reply::parse(&body)?; + if reply.status >= 0x0400 { + return Err(Error::Status(reply.status, reply.status_message())); + } + Ok(reply) +} + +/// Lowercase HTTP headers. +type Headers = Vec<(String, String)>; + +/// Reads one HTTP response: the headers, then as much body as `Content-Length` says, or up to +/// the last chunk, or to the end of the stream when the server says neither. A server that keeps +/// the connection open would otherwise hold the reply until the read times out. +fn read_response(stream: &mut Stream) -> Result> { + let mut raw = Vec::new(); + let mut buf = [0u8; 16 * 1024]; + let mut body_at = None; + let mut expected: Option = None; + let mut chunked = false; + loop { + let n = stream.read(&mut buf).map_err(|e| Error::Protocol(format!("reading: {e}")))?; + if n == 0 { + return Ok(raw); + } + raw.extend_from_slice(buf.get(..n).unwrap_or_default()); + if raw.len() > MAX_REPLY { + return Err(Error::Protocol("the reply is larger than 16 MiB".into())); + } + if body_at.is_none() + && let Some(end) = raw.windows(4).position(|w| w == b"\r\n\r\n") + { + body_at = Some(end + 4); + let (_, headers, _) = http_response(&raw)?; + expected = headers.iter().find(|(k, _)| k == "content-length").and_then(|(_, v)| v.parse().ok()); + chunked = headers.iter().any(|(k, v)| k == "transfer-encoding" && v.contains("chunked")); + } + let Some(at) = body_at else { continue }; + let body = raw.get(at..).unwrap_or_default(); + let complete = match expected { + Some(len) if !chunked => body.len() >= len, + _ if chunked => chunked_complete(body), + _ => false, + }; + if complete { + if let Some(len) = expected.filter(|_| !chunked) { + raw.truncate(at + len); + } + return Ok(raw); + } + } +} + +/// Splits an HTTP/1.1 response into status, lowercase headers and body. +fn http_response(raw: &[u8]) -> Result<(u16, Headers, &[u8])> { + let end = raw.windows(4).position(|w| w == b"\r\n\r\n").ok_or_else(|| Error::Protocol("no HTTP header".into()))?; + let head = String::from_utf8_lossy(raw.get(..end).unwrap_or(&[])); + let mut lines = head.split("\r\n"); + let status_line = lines.next().unwrap_or_default(); + let status: u16 = + status_line.split_whitespace().nth(1).and_then(|s| s.parse().ok()).ok_or_else(|| Error::Protocol(format!("bad status line `{status_line}`")))?; + let headers = lines.filter_map(|l| l.split_once(':')).map(|(k, v)| (k.trim().to_ascii_lowercase(), v.trim().to_ascii_lowercase())).collect(); + Ok((status, headers, raw.get(end + 4..).unwrap_or(&[]))) +} + +/// Whether a chunked body has reached its last (zero-size) chunk, walking the chunk sizes so a +/// `0\r\n\r\n` inside a chunk's payload (an attribute value can hold it) does not count. A +/// malformed or impossible size counts as complete, so [`dechunk`] reports it instead of +/// waiting for more. +fn chunked_complete(mut b: &[u8]) -> bool { + loop { + let Some(line_end) = b.windows(2).position(|w| w == b"\r\n") else { return false }; + let size_str = String::from_utf8_lossy(b.get(..line_end).unwrap_or(&[])); + let Ok(size) = usize::from_str_radix(size_str.split(';').next().unwrap_or_default().trim(), 16) else { return true }; + b = b.get(line_end + 2..).unwrap_or(&[]); + if size == 0 { + return true; + } + // A size past the reply cap can never be satisfied: complete, so `dechunk` rejects it + // (checked, so `ffffffffffffffff` cannot overflow the slice arithmetic). + let Some(end) = size.checked_add(2).filter(|_| size <= MAX_REPLY) else { return true }; + let Some(rest) = b.get(end..) else { return false }; + b = rest; + } +} + +/// Joins the chunks of a chunked transfer encoding. +fn dechunk(mut b: &[u8]) -> Result> { + let mut out = Vec::new(); + loop { + let line_end = b.windows(2).position(|w| w == b"\r\n").ok_or_else(|| Error::Protocol("cut chunk size".into()))?; + let size_str = String::from_utf8_lossy(b.get(..line_end).unwrap_or(&[])); + let size = usize::from_str_radix(size_str.split(';').next().unwrap_or_default().trim(), 16).map_err(|_| Error::Protocol("bad chunk size".into()))?; + b = b.get(line_end + 2..).unwrap_or(&[]); + if size == 0 { + return Ok(out); + } + let chunk = b.get(..size).ok_or_else(|| Error::Protocol("cut chunk".into()))?; + out.extend_from_slice(chunk); + if out.len() > MAX_REPLY { + return Err(Error::Protocol("the reply is larger than 16 MiB".into())); + } + b = b.get(size + 2..).ok_or_else(|| Error::Protocol("cut chunk end".into()))?; + } +} + +/// Where `/path` lands on the server: `/printers/` or `/classes/`. +fn queue_path(name: &str, is_class: bool) -> String { + format!("/{}/{name}", if is_class { "classes" } else { "printers" }) +} + +/// The printers the server knows (CUPS-Get-Printers), then its classes (CUPS-Get-Classes, which +/// `lp -d` accepts just the same), the default one flagged. +pub fn printers(endpoint: &Endpoint) -> Result> { + let mut out = queues(endpoint, OP_CUPS_GET_PRINTERS, false)?; + // A server without classes answers "not found"; an older server may not know the operation. + out.extend(queues(endpoint, OP_CUPS_GET_CLASSES, true).unwrap_or_default()); + let default = default_printer(endpoint).unwrap_or(None); + for p in &mut out { + p.is_default = default.as_deref() == Some(p.name.as_str()); + } + Ok(out) +} + +fn queues(endpoint: &Endpoint, op: u16, is_class: bool) -> Result> { + let mut r = Request::new(op, 1); + r.attr_many(TAG_KEYWORD, "requested-attributes", &["printer-name", "printer-info", "printer-state", "printer-is-accepting-jobs"]); + let reply = match post(endpoint, "/", &r.end()) { + Ok(reply) => reply, + // CUPS answers "not found" ("No destinations added.") when no printer is set up. + Err(Error::Status(0x0406, _)) => return Ok(Vec::new()), + Err(e) => return Err(e), + }; + let mut out = Vec::new(); + let mut current: Option = None; + for a in reply.attributes.iter().filter(|a| a.group == TAG_PRINTER_GROUP) { + match a.name.as_str() { + "printer-name" => { + if let Some(p) = current.take() { + out.push(p); + } + current = Some(Printer { name: a.text(), info: String::new(), state: 3, accepting_jobs: true, is_default: false, is_class }); + } + "printer-info" => { + if let Some(p) = &mut current { + p.info = a.text(); + } + } + "printer-state" => { + if let Some(p) = &mut current { + p.state = a.int().unwrap_or(3).clamp(0, 5) as u32; + } + } + "printer-is-accepting-jobs" => { + if let Some(p) = &mut current { + p.accepting_jobs = a.values.first().is_none_or(|v| v.first() != Some(&0)); + } + } + _ => {} + } + } + out.extend(current); + Ok(out) +} + +/// The server's default printer (CUPS-Get-Default), `None` when there is none. +pub fn default_printer(endpoint: &Endpoint) -> Result> { + let mut r = Request::new(OP_CUPS_GET_DEFAULT, 2); + r.attr_many(TAG_KEYWORD, "requested-attributes", &["printer-name"]); + match post(endpoint, "/", &r.end()) { + Ok(reply) => Ok(reply.attributes.iter().find(|a| a.name == "printer-name").map(Attribute::text).filter(|n| !n.is_empty())), + // CUPS answers "not found" when no default is set. + Err(Error::Status(0x0406, _)) => Ok(None), + Err(e) => Err(e), + } +} + +/// What to print. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct Job<'a> { + pub printer: &'a str, + /// `printer` names a class. + pub is_class: bool, + /// Shown in the queue. + pub title: &'a str, + pub user: &'a str, + pub copies: u32, + /// The document, as `format` (`application/pdf`). + pub document: &'a [u8], + pub format: &'a str, +} + +/// Submits `job` (Print-Job) and returns the job id. +pub fn print_job(endpoint: &Endpoint, job: &Job<'_>) -> Result { + let mut r = Request::new(OP_PRINT_JOB, 3); + let path = queue_path(job.printer, job.is_class); + r.attr(TAG_URI, "printer-uri", format!("ipp://{}{path}", endpoint.host()).as_bytes()); + r.attr(TAG_NAME, "requesting-user-name", job.user.as_bytes()); + r.attr(TAG_NAME, "job-name", job.title.as_bytes()); + r.attr(TAG_MIME, "document-format", job.format.as_bytes()); + if job.copies > 1 { + r.buf.push(TAG_JOB_GROUP); + r.integer("copies", job.copies.clamp(1, 999) as i32); + } + let mut body = r.end(); + body.extend_from_slice(job.document); + let reply = post(endpoint, &path, &body)?; + reply + .attributes + .iter() + .find(|a| a.name == "job-id") + .and_then(Attribute::int) + .map(|id| id.max(0) as u32) + .ok_or_else(|| Error::Protocol("no job id in the reply".into())) +} + +/// A request as a server sees it: the operation (the request's status field), its attributes +/// and the document data after the end tag. For fakes in tests and tools. +pub fn parse_request(b: &[u8]) -> Result<(u16, Vec, Vec)> { + let (reply, end) = Reply::parse_at(b)?; + Ok((reply.status, reply.attributes, b.get(end..).unwrap_or(&[]).to_vec())) +} + +#[cfg(test)] +mod tests; diff --git a/crates/ipp/src/tests.rs b/crates/ipp/src/tests.rs new file mode 100644 index 0000000000..ff3ccb6a49 --- /dev/null +++ b/crates/ipp/src/tests.rs @@ -0,0 +1,395 @@ +//! The client against an in-process fake CUPS: TCP and Unix socket, content-length and chunked +//! replies, every operation, error statuses and broken replies. + +use super::*; +use std::io::{BufRead, BufReader}; +use std::sync::mpsc; + +/// What the fake received: op, attributes, document. +type Seen = (u16, Vec, Vec); + +/// A fake server answering one request with `reply_body` (IPP bytes), chunked or not, under an +/// HTTP status. Returns the endpoint and a channel with what it received. +fn fake(reply: Vec, chunked: bool, http_status: u16) -> (Endpoint, mpsc::Receiver) { + let listener = std::net::TcpListener::bind("127.0.0.1:0").unwrap(); + let port = listener.local_addr().unwrap().port(); + let (tx, rx) = mpsc::channel(); + std::thread::spawn(move || { + let (stream, _) = listener.accept().unwrap(); + serve(Stream::Tcp(stream), reply, chunked, http_status, &tx); + }); + (Endpoint::Tcp("127.0.0.1".into(), port), rx) +} + +fn serve(stream: Stream, reply: Vec, chunked: bool, http_status: u16, tx: &mpsc::Sender) { + let mut reader = BufReader::new(stream); + let mut line = String::new(); + let mut content_length = 0usize; + let mut first = String::new(); + loop { + line.clear(); + reader.read_line(&mut line).unwrap(); + if first.is_empty() { + first = line.clone(); + } + if line == "\r\n" || line.is_empty() { + break; + } + if let Some(v) = line.to_ascii_lowercase().strip_prefix("content-length:") { + content_length = v.trim().parse().unwrap(); + } + } + if first.is_empty() { + return; // a reachability probe: connect and close + } + assert!(first.starts_with("POST "), "{first}"); + let mut body = vec![0u8; content_length]; + reader.read_exact(&mut body).unwrap(); + let _ = tx.send(parse_request(&body).unwrap()); + let mut out = reader.into_inner(); + let status_text = if http_status == 200 { "OK" } else { "Nope" }; + if chunked { + let mut resp = format!("HTTP/1.1 {http_status} {status_text}\r\nContent-Type: application/ipp\r\nTransfer-Encoding: chunked\r\n\r\n").into_bytes(); + for chunk in reply.chunks(7) { + resp.extend_from_slice(format!("{:x}\r\n", chunk.len()).as_bytes()); + resp.extend_from_slice(chunk); + resp.extend_from_slice(b"\r\n"); + } + resp.extend_from_slice(b"0\r\n\r\n"); + out.write_all(&resp).unwrap(); + } else { + out.write_all(format!("HTTP/1.1 {http_status} {status_text}\r\nContent-Type: application/ipp\r\nContent-Length: {}\r\n\r\n", reply.len()).as_bytes()) + .unwrap(); + out.write_all(&reply).unwrap(); + } + out.flush().unwrap(); +} + +/// An attribute to write: tag, name, values. +type Attr<'a> = (u8, &'a str, &'a [&'a [u8]]); +/// A group to write: tag and attributes. +type Group<'a> = (u8, &'a [Attr<'a>]); + +/// An IPP reply body. +fn ipp(status: u16, groups: &[Group<'_>]) -> Vec { + let mut b = vec![2, 0]; + b.extend_from_slice(&status.to_be_bytes()); + b.extend_from_slice(&1u32.to_be_bytes()); + for (group, attrs) in groups { + b.push(*group); + for (tag, name, values) in *attrs { + for (i, v) in values.iter().enumerate() { + b.push(*tag); + let n = if i == 0 { name.as_bytes() } else { b"" }; + b.extend_from_slice(&(n.len() as u16).to_be_bytes()); + b.extend_from_slice(n); + b.extend_from_slice(&(v.len() as u16).to_be_bytes()); + b.extend_from_slice(v); + } + } + } + b.push(TAG_END); + b +} + +fn ok_header() -> Group<'static> { + (TAG_OPERATION_GROUP, &[(TAG_CHARSET, "attributes-charset", &[b"utf-8"]), (TAG_LANGUAGE, "attributes-natural-language", &[b"en"])]) +} + +#[test] +fn printers_are_listed_with_state_and_default() { + let three = ipp( + 0, + &[ + ok_header(), + ( + TAG_PRINTER_GROUP, + &[ + (TAG_NAME, "printer-name", &[b"Office"]), + (TAG_TEXT, "printer-info", &[b"HP in the office"]), + (TAG_ENUM, "printer-state", &[&3u32.to_be_bytes()]), + (TAG_BOOLEAN, "printer-is-accepting-jobs", &[&[1]]), + ], + ), + ( + TAG_PRINTER_GROUP, + &[ + (TAG_NAME, "printer-name", &[b"Stopped"]), + (TAG_ENUM, "printer-state", &[&5u32.to_be_bytes()]), + (TAG_BOOLEAN, "printer-is-accepting-jobs", &[&[0]]), + ], + ), + ], + ); + for chunked in [false, true] { + let (ep, rx) = fake(three.clone(), chunked, 200); + // `printers` asks for the default too; that second request reaches a server that has + // closed, so the default is simply unknown here. + let list = printers(&ep).unwrap(); + let (op, attrs, doc) = rx.recv().unwrap(); + assert_eq!(op, OP_CUPS_GET_PRINTERS); + assert!(attrs.iter().any(|a| a.name == "requested-attributes" && a.values.len() == 4 && a.tag == TAG_KEYWORD)); + assert!(doc.is_empty()); + assert_eq!(list.len(), 2, "chunked {chunked}"); + assert_eq!((list[0].name.as_str(), list[0].info.as_str(), list[0].state, list[0].accepting_jobs), ("Office", "HP in the office", 3, true)); + assert_eq!((list[1].name.as_str(), list[1].state, list[1].accepting_jobs), ("Stopped", 5, false)); + assert!(!list[0].is_default && !list[1].is_default); + } +} + +#[test] +fn default_printer_is_read_and_absence_is_not_an_error() { + let (ep, rx) = fake(ipp(0, &[ok_header(), (TAG_PRINTER_GROUP, &[(TAG_NAME, "printer-name", &[b"Office"])])]), false, 200); + assert_eq!(default_printer(&ep).unwrap(), Some("Office".into())); + assert_eq!(rx.recv().unwrap().0, OP_CUPS_GET_DEFAULT); + let (ep, _rx) = fake(ipp(0x0406, &[ok_header(), (TAG_OPERATION_GROUP, &[(TAG_TEXT, "status-message", &[b"No default printer"])])]), false, 200); + assert_eq!(default_printer(&ep).unwrap(), None); +} + +#[test] +fn a_print_job_carries_the_document_and_returns_its_id() { + let (ep, rx) = fake(ipp(0, &[ok_header(), (TAG_JOB_GROUP, &[(TAG_INTEGER, "job-id", &[&42u32.to_be_bytes()])])]), true, 200); + let pdf = b"%PDF-1.4 fake".to_vec(); + let id = + print_job(&ep, &Job { printer: "Office", is_class: false, title: "photo.psd", user: "diego", copies: 2, document: &pdf, format: "application/pdf" }) + .unwrap(); + assert_eq!(id, 42); + let (op, attrs, doc) = rx.recv().unwrap(); + assert_eq!(op, OP_PRINT_JOB); + assert_eq!(doc, pdf); + let get = |n: &str| attrs.iter().find(|a| a.name == n).cloned().unwrap_or_else(|| panic!("{n}")); + assert_eq!(get("printer-uri").values[0], format!("ipp://{}/printers/Office", ep.host()).as_bytes()); + assert_eq!(get("job-name").values[0], b"photo.psd"); + assert_eq!(get("requesting-user-name").values[0], b"diego"); + assert_eq!(get("document-format").values[0], b"application/pdf"); + let copies = get("copies"); + assert_eq!((copies.group, copies.tag, copies.int()), (TAG_JOB_GROUP, TAG_INTEGER, Some(2))); + // One copy: no job attributes group at all. + let (ep, rx) = fake(ipp(0, &[ok_header(), (TAG_JOB_GROUP, &[(TAG_INTEGER, "job-id", &[&7u32.to_be_bytes()])])]), false, 200); + print_job(&ep, &Job { printer: "P", is_class: false, title: "t", user: "u", copies: 1, document: b"x", format: "application/pdf" }).unwrap(); + assert!(!rx.recv().unwrap().1.iter().any(|a| a.name == "copies")); +} + +#[test] +fn error_statuses_and_broken_replies_are_errors() { + let (ep, _rx) = fake(ipp(0x0506, &[ok_header(), (TAG_OPERATION_GROUP, &[(TAG_TEXT, "status-message", &[b"Printer is not accepting jobs"])])]), false, 200); + let e = print_job(&ep, &Job { printer: "P", is_class: false, title: "t", user: "u", copies: 1, document: b"x", format: "application/pdf" }).unwrap_err(); + assert_eq!(e, Error::Status(0x0506, "Printer is not accepting jobs".into())); + assert!(e.to_string().contains("0x0506")); + // A CUPS with no printer at all answers "not found": that is an empty list, not an error. + let (ep, _rx) = fake(ipp(0x0406, &[ok_header(), (TAG_OPERATION_GROUP, &[(TAG_TEXT, "status-message", &[b"No destinations added."])])]), false, 200); + assert_eq!(printers(&ep).unwrap(), Vec::::new()); + // HTTP 401, a reply cut short, garbage. + let (ep, _rx) = fake(ipp(0, &[ok_header()]), false, 401); + assert!(matches!(printers(&ep), Err(Error::Protocol(m)) if m.contains("401"))); + let (ep, _rx) = fake(vec![2, 0, 0, 0, 0, 0, 0, 1, TAG_OPERATION_GROUP, TAG_NAME, 0, 5], true, 200); + assert!(matches!(printers(&ep), Err(Error::Protocol(_)))); + let (ep, _rx) = fake(b"not ipp at all".to_vec(), false, 200); + assert!(matches!(printers(&ep), Err(Error::Protocol(_)))); + // Nobody listening. + let e = printers(&Endpoint::Tcp("127.0.0.1".into(), 1)).unwrap_err(); + assert!(matches!(e, Error::Unreachable(_)), "{e}"); +} + +#[test] +fn reply_parsing_is_bounded() { + assert!(Reply::parse(&[]).is_err()); + assert!(Reply::parse(&[2, 0, 0, 0, 0, 0, 0, 1]).is_err(), "no end tag"); + let mut many = vec![2, 0, 0, 0, 0, 0, 0, 1, TAG_OPERATION_GROUP]; + for _ in 0..MAX_ATTRIBUTES + 1 { + many.extend_from_slice(&[TAG_NAME, 0, 1, b'a', 0, 0]); + } + many.push(TAG_END); + assert!(matches!(Reply::parse(&many), Err(Error::Protocol(m)) if m.contains("too many"))); + // An additional value before any attribute. + assert!(Reply::parse(&[2, 0, 0, 0, 0, 0, 0, 1, TAG_OPERATION_GROUP, TAG_NAME, 0, 0, 0, 1, b'x', TAG_END]).is_err()); + assert!(dechunk(b"zz\r\n").is_err() && dechunk(b"5\r\nab").is_err()); + assert_eq!(dechunk(b"3\r\nabc\r\n0\r\n\r\n").unwrap(), b"abc"); +} + +#[cfg(unix)] +#[test] +fn the_unix_socket_endpoint_works_and_detect_honours_cups_server() { + let dir = std::env::temp_dir().join(format!("pc-ipp-{}", std::process::id())); + let _ = std::fs::remove_dir_all(&dir); + std::fs::create_dir_all(&dir).unwrap(); + let sock = dir.join("cups.sock"); + let listener = std::os::unix::net::UnixListener::bind(&sock).unwrap(); + let (tx, rx) = mpsc::channel(); + let reply = ipp(0, &[ok_header(), (TAG_PRINTER_GROUP, &[(TAG_NAME, "printer-name", &[b"Sock"])])]); + std::thread::spawn(move || { + for _ in 0..3 { + let (stream, _) = listener.accept().unwrap(); + serve(Stream::Unix(stream), reply.clone(), false, 200, &tx); + } + }); + let ep = Endpoint::Unix(sock.clone()); + assert!(ep.reachable()); + assert_eq!(default_printer(&ep).unwrap(), Some("Sock".into())); + assert_eq!(rx.recv().unwrap().0, OP_CUPS_GET_DEFAULT); + assert!(!Endpoint::Unix(dir.join("missing.sock")).reachable()); + assert_eq!(ep.to_string(), sock.display().to_string()); + let _ = std::fs::remove_dir_all(&dir); +} + +#[test] +fn requests_start_with_the_mandatory_attributes() { + let r = Request::new(OP_PRINT_JOB, 9).end(); + let (op, attrs, doc) = parse_request(&r).unwrap(); + assert_eq!(op, OP_PRINT_JOB); + assert_eq!(Reply::parse(&r).unwrap().request_id, 9); + assert_eq!(attrs[0].name, "attributes-charset"); + assert_eq!(attrs[1].name, "attributes-natural-language"); + assert!(doc.is_empty()); +} + +/// A server that keeps the connection open after its reply (a printer reached directly, a proxy) +/// must not hold the client until the read times out: the reply ends at Content-Length, or at +/// the last chunk. +#[test] +fn replies_end_at_content_length_or_the_last_chunk_without_waiting_for_close() { + for chunked in [false, true] { + let listener = std::net::TcpListener::bind("127.0.0.1:0").unwrap(); + let port = listener.local_addr().unwrap().port(); + let one = ipp(0, &[ok_header(), (TAG_PRINTER_GROUP, &[(TAG_NAME, "printer-name", &[b"Office"])])]); + let none = ipp(0x0406, &[ok_header()]); + std::thread::spawn(move || { + for stream in listener.incoming() { + let Ok(stream) = stream else { return }; + let mut reader = BufReader::new(stream); + let (mut line, mut len) = (String::new(), 0usize); + loop { + line.clear(); + if reader.read_line(&mut line).unwrap_or(0) == 0 || line == "\r\n" { + break; + } + if let Some(v) = line.to_ascii_lowercase().strip_prefix("content-length:") { + len = v.trim().parse().unwrap_or(0); + } + } + if len == 0 { + continue; + } + let mut body = vec![0u8; len]; + if reader.read_exact(&mut body).is_err() { + continue; + } + let op = parse_request(&body).map(|r| r.0).unwrap_or(0); + let reply = if op == OP_CUPS_GET_PRINTERS { one.clone() } else { none.clone() }; + let mut out = reader.into_inner(); + let mut resp = Vec::new(); + if chunked { + resp.extend_from_slice(b"HTTP/1.1 200 OK\r\nContent-Type: application/ipp\r\nTransfer-Encoding: chunked\r\n\r\n"); + for chunk in reply.chunks(5) { + resp.extend_from_slice(format!("{:x}\r\n", chunk.len()).as_bytes()); + resp.extend_from_slice(chunk); + resp.extend_from_slice(b"\r\n"); + } + resp.extend_from_slice(b"0\r\n\r\n"); + } else { + resp.extend_from_slice(format!("HTTP/1.1 200 OK\r\nContent-Type: application/ipp\r\nContent-Length: {}\r\n\r\n", reply.len()).as_bytes()); + resp.extend_from_slice(&reply); + } + if out.write_all(&resp).and_then(|()| out.flush()).is_err() { + continue; + } + // Keep this connection open for a while, on its own thread, and go on serving. + std::thread::spawn(move || { + std::thread::sleep(Duration::from_secs(10)); + drop(out); + }); + } + }); + let ep = Endpoint::Tcp("127.0.0.1".into(), port); + let started = std::time::Instant::now(); + let list = printers(&ep).unwrap(); + assert_eq!(list.len(), 1, "chunked {chunked}"); + assert!(started.elapsed() < Duration::from_secs(5), "waited for the server to close (chunked {chunked}): {:?}", started.elapsed()); + } +} + +/// A chunked reply whose payload contains the bytes of a last chunk (`\r\n0\r\n\r\n`, here inside +/// a printer name) and arrives split right after them: the reader must wait for the real last +/// chunk instead of returning a cut reply. +#[test] +fn a_last_chunk_pattern_inside_a_value_does_not_end_the_reply() { + let listener = std::net::TcpListener::bind("127.0.0.1:0").unwrap(); + let port = listener.local_addr().unwrap().port(); + let reply = ipp(0, &[ok_header(), (TAG_PRINTER_GROUP, &[(TAG_NAME, "printer-name", &[b"Of\r\n0\r\n\r\nfice"])])]); + let cut = reply.windows(7).position(|w| w == b"\r\n0\r\n\r\n").unwrap() + 7; + std::thread::spawn(move || { + let (stream, _) = listener.accept().unwrap(); + let mut reader = BufReader::new(stream); + let (mut line, mut len) = (String::new(), 0usize); + loop { + line.clear(); + if reader.read_line(&mut line).unwrap_or(0) == 0 || line == "\r\n" { + break; + } + if let Some(v) = line.to_ascii_lowercase().strip_prefix("content-length:") { + len = v.trim().parse().unwrap_or(0); + } + } + let mut body = vec![0u8; len]; + reader.read_exact(&mut body).unwrap(); + let mut out = reader.into_inner(); + let (first, second) = reply.split_at(cut); + // Part one ends exactly on the pattern, inside the first chunk's payload. + let mut a = b"HTTP/1.1 200 OK\r\nContent-Type: application/ipp\r\nTransfer-Encoding: chunked\r\n\r\n".to_vec(); + a.extend_from_slice(format!("{:x}\r\n", first.len()).as_bytes()); + a.extend_from_slice(first); + out.write_all(&a).and_then(|()| out.flush()).unwrap(); + std::thread::sleep(Duration::from_millis(400)); + let mut b = b"\r\n".to_vec(); + b.extend_from_slice(format!("{:x}\r\n", second.len()).as_bytes()); + b.extend_from_slice(second); + b.extend_from_slice(b"\r\n0\r\n\r\n"); + out.write_all(&b).and_then(|()| out.flush()).unwrap(); + }); + let ep = Endpoint::Tcp("127.0.0.1".into(), port); + let list = printers(&ep).unwrap(); + assert_eq!(list.len(), 1); + assert_eq!(list[0].name, "Of\r\n0\r\n\r\nfice"); + // The boundary walk itself: complete only at a zero-size chunk, never on payload bytes. + assert!(!chunked_complete(b"5\r\nab\r\n0")); + assert!(!chunked_complete(b"7\r\n\r\n0\r\n\r\nx\r\n")); + assert!(chunked_complete(b"7\r\n\r\n0\r\n\r\nx\r\n0\r\n\r\n")); + assert!(chunked_complete(b"zz\r\n") && dechunk(b"zz\r\n").is_err()); +} + +/// A chunk size that cannot be satisfied (`usize::MAX`, `usize::MAX - 1`, anything past the +/// reply cap) is a protocol error, through the real reader, never an overflow panic. +#[test] +fn an_impossible_chunk_size_is_a_protocol_error_not_a_panic() { + for size_line in ["ffffffffffffffff", "fffffffffffffffe", "1000001"] { + assert!(chunked_complete(format!("{size_line}\r\n").as_bytes())); + assert!(dechunk(format!("{size_line}\r\nab\r\n").as_bytes()).is_err()); + let listener = std::net::TcpListener::bind("127.0.0.1:0").unwrap(); + let port = listener.local_addr().unwrap().port(); + let line = size_line.to_string(); + std::thread::spawn(move || { + let (stream, _) = listener.accept().unwrap(); + let mut reader = BufReader::new(stream); + let (mut l, mut len) = (String::new(), 0usize); + loop { + l.clear(); + if reader.read_line(&mut l).unwrap_or(0) == 0 || l == "\r\n" { + break; + } + if let Some(v) = l.to_ascii_lowercase().strip_prefix("content-length:") { + len = v.trim().parse().unwrap_or(0); + } + } + let mut body = vec![0u8; len]; + reader.read_exact(&mut body).unwrap(); + let mut out = reader.into_inner(); + let resp = format!("HTTP/1.1 200 OK\r\nContent-Type: application/ipp\r\nTransfer-Encoding: chunked\r\n\r\n{line}\r\n"); + let _ = out.write_all(resp.as_bytes()).and_then(|()| out.flush()); + }); + let ep = Endpoint::Tcp("127.0.0.1".into(), port); + match printers(&ep) { + Err(Error::Protocol(_)) => {} + other => panic!("{size_line}: expected a protocol error, got {other:?}"), + } + } +} diff --git a/docs/development.md b/docs/development.md index 239f9456bf..6891e88a9c 100644 --- a/docs/development.md +++ b/docs/development.md @@ -126,6 +126,7 @@ By default PhotoCraft's own crates log at `info` and everything else at `warn`. | `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_PRINT_PORTAL=1` | Linux: File › Print always opens the system print dialog (the XDG print portal) instead of talking to CUPS; `0` never does. See [printing](printing.md). | | `PHOTOCRAFT_THEME_FILE=tokens.json` | **Debug builds only:** live design-token overrides, re-read on change | ### Live design tokens diff --git a/docs/printing.md b/docs/printing.md new file mode 100644 index 0000000000..68015d278e --- /dev/null +++ b/docs/printing.md @@ -0,0 +1,67 @@ +# Printing + +File › Print renders the flattened document onto one PDF page (paper size and orientation, +position and scale, crop and registration marks, labels, colour handling) and hands that page +to a printer. The rendering is `print_cmds.rs` in `photocraft-engine`, shared by the dialog, the +batch commands and automation (`file.print`, `file.printOneCopy`); this page is about the hand-off. + +## Routes to the printer + +| Route | When | What the user sees | +|---|---|---| +| **`craft-print`** (`lp` on CUPS, the Windows spooler) | Whenever `lp` is on the path (macOS, Linux outside a sandbox) or on Windows | `Sent to the printer (request id is Office-42)` | +| **CUPS over IPP** (`photocraft-ipp`) | No `lp` on the path (a Flatpak) and a print server answers: `CUPS_SERVER`, then `/run/cups/cups.sock`, `/var/run/cups/cups.sock`, `/private/var/run/cupsd`, then `localhost:631`; always for a `printServer` named in the request | `Sent to the printer (job 42 on Office)` | +| **Print portal** (`org.freedesktop.portal.Print`, desktop app on Linux) | No print server reachable or no printer set up, or always with `PHOTOCRAFT_PRINT_PORTAL=1` | The system print dialog, seeded with the copies and paper; the outcome goes to the log | +| **Print to PDF** | `output` given (the Output field of the dialog) | The file; nothing is spooled unless `send` is true | + +A route that exists but fails (an unknown printer, a job the queue refuses) is an error; only a +missing route (no `lp`, no print server, no printer) moves on to the next one. + +The IPP client is in-process, pure Rust (RFC 8010/8011: `CUPS-Get-Printers`, `CUPS-Get-Classes`, +`CUPS-Get-Default`, `Print-Job` with the PDF as the document), so no `lp` binary is needed. That +is what makes printing work in the Flatpak: the manifest grants `--socket=cups`, which mounts the +host's CUPS socket, and the sandbox has no `lp`. On a system without CUPS, or in a sandbox +without the socket, the app falls back to the print portal, whose dialog runs outside the sandbox. + +## Which printer + +The dialog's Printer field, or `"printer"` in the command, names a queue (case-insensitively; a +CUPS class counts, as with `lp -d`). +Left empty, the server's default printer is used; without a default, the only printer; with +several and no default, the error names them. With no printer at all, the portal takes over when +it is available, otherwise the error says to add one in the system settings or print to PDF. + +`file.printers` lists what the print server knows: `{"printServer": "host[:port]|socket"}` +is optional and defaults to the system's CUPS; the result is +`{server, default, printers: [{name, info, state, acceptingJobs, default, class}]}`, or an empty list +with a `note` when no server answers. `"printServer"` on `file.print` sends one job to a named +server; `"dryRun": true` renders the page and reports the equivalent `lp` command without +spooling. + +## Results + +`file.print` returns `sent`, `backend` (`lp`, `windows`, `ipp`, `portal`), `spooler` (where the +job went) and `pdf` (the page's path, only when `output` named one). + +## Environment + +| Variable | Effect | +|---|---| +| `CUPS_SERVER` | The print server, as CUPS itself reads it: `host[:port]` or a socket path | +| `PHOTOCRAFT_PRINT_PORTAL=1` | Always use the system print dialog (the portal) instead of `lp` or CUPS directly | +| `PHOTOCRAFT_PRINT_PORTAL=0` | Never use the portal; a missing print server is then an error | + +## Verification + +- `crates/ipp/src/tests.rs`: the client against a fake CUPS over TCP and a Unix socket: + printer listing with state and default, the default printer and its absence, `Print-Job` + with the document and the job id, chunked and content-length replies, HTTP and IPP error + statuses, broken replies. +- `crates/engine/src/print_cmds/tests.rs`: printer resolution (requested, default, only one, + several without a default, unknown, none); a complete `file.print` against a fake CUPS + (printer URI, copies, job name, document format, the PDF itself) and `file.printers`; + `lp` taking the job when it is on the path; the IPP route when it is not (a child process + with an empty path); the app's spooler when preferred, as the fallback without a printer, and + skipped when a server is named; print to PDF untouched. +- `apps/photocraft/src/print_portal.rs`: the request path rule and the paper/copies seeding of + the dialog (PWG names for the standard papers, millimetres for custom sizes). diff --git a/docs/scorecard.md b/docs/scorecard.md index 377ce8583d..3b36e7c611 100644 --- a/docs/scorecard.md +++ b/docs/scorecard.md @@ -12,7 +12,7 @@ Generated by `cargo xtask scorecard` from `perf/budgets.toml`, `perf/baseline.js | [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 | +| [Reliability](#reliability) | 5 | 2 | 0 | never-crash lints on 30 of 30 crates; panic_hunt present | | [Distribution](#distribution) | 5 | 4 | 2 | | | [Open bugs](#open-bugs) | 29 | 2 | 1 | | @@ -243,7 +243,7 @@ Menu wiring (`docs/parity-checklist.md`): 631 of 631 menu items live (100.0%). T ## Reliability -Never-crash attribute on 29 of 29 crate roots. +Never-crash attribute on 30 of 30 crate roots. | Id | Target | Status | Issue | Note | |---|---|---|---|---| diff --git a/packaging/linux/flatpak/ai.storyteller.photocraft.bundle.yml b/packaging/linux/flatpak/ai.storyteller.photocraft.bundle.yml index 0b771ae7c7..c7d30ac7d3 100644 --- a/packaging/linux/flatpak/ai.storyteller.photocraft.bundle.yml +++ b/packaging/linux/flatpak/ai.storyteller.photocraft.bundle.yml @@ -24,6 +24,9 @@ finish-args: - --share=ipc # GPU access for the wgpu compositor (Vulkan, or GL as a fallback). - --device=dri + # Printing: the host's CUPS socket, so File › Print reaches the print server directly (IPP, + # in process). Without it the app shows the system print dialog through the print portal. + - --socket=cups # Open/Save dialogs go through the file-chooser portal (rfd's xdg-portal backend) and # drag-and-drop through the document portal, so picked files work anywhere without broad # filesystem access. Pictures and Documents are granted so files passed by path (the command diff --git a/packaging/linux/flatpak/ai.storyteller.photocraft.yml b/packaging/linux/flatpak/ai.storyteller.photocraft.yml index ee6bbfac1b..e3ac74cffd 100644 --- a/packaging/linux/flatpak/ai.storyteller.photocraft.yml +++ b/packaging/linux/flatpak/ai.storyteller.photocraft.yml @@ -25,6 +25,9 @@ finish-args: - --share=ipc # GPU access for the wgpu compositor (Vulkan, or GL as a fallback). - --device=dri + # Printing: the host's CUPS socket, so File › Print reaches the print server directly (IPP, + # in process). Without it the app shows the system print dialog through the print portal. + - --socket=cups # Open/Save dialogs go through the file-chooser portal (rfd's xdg-portal backend) and # drag-and-drop through the document portal, so picked files work anywhere without broad # filesystem access. Pictures and Documents are granted so files passed by path (the command diff --git a/xtask/src/layers.rs b/xtask/src/layers.rs index 3d162dd458..f2e6f53514 100644 --- a/xtask/src/layers.rs +++ b/xtask/src/layers.rs @@ -41,6 +41,8 @@ pub const TABLE: &[(&str, Class)] = &[ ("codecs", Class::Standalone), // Optional HEIF/HEIC decoder (heic-rs), used only by `codecs` behind its `heif` feature. ("heif", Class::Standalone), + // IPP client for CUPS printing (pure Rust, no dependencies), used by `engine` on Unix. + ("ipp", Class::Standalone), ("raw", Class::Standalone), ("affinity", Class::Standalone), ("adobe-assets", Class::Standalone),